|
| 1 | +import dns from 'dns/promises' |
1 | 2 | import { createLogger } from '@/lib/logs/console/logger' |
2 | 3 |
|
3 | 4 | const logger = createLogger('InputValidation') |
@@ -850,3 +851,110 @@ export function validateProxyUrl( |
850 | 851 | ): ValidationResult { |
851 | 852 | return validateExternalUrl(url, paramName) |
852 | 853 | } |
| 854 | + |
| 855 | +/** |
| 856 | + * Checks if an IP address is private or reserved (not routable on the public internet) |
| 857 | + */ |
| 858 | +function isPrivateOrReservedIP(ip: string): boolean { |
| 859 | + const patterns = [ |
| 860 | + /^127\./, // Loopback |
| 861 | + /^10\./, // Private Class A |
| 862 | + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B |
| 863 | + /^192\.168\./, // Private Class C |
| 864 | + /^169\.254\./, // Link-local |
| 865 | + /^0\./, // Current network |
| 866 | + /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // Carrier-grade NAT |
| 867 | + /^192\.0\.0\./, // IETF Protocol Assignments |
| 868 | + /^192\.0\.2\./, // TEST-NET-1 |
| 869 | + /^198\.51\.100\./, // TEST-NET-2 |
| 870 | + /^203\.0\.113\./, // TEST-NET-3 |
| 871 | + /^224\./, // Multicast |
| 872 | + /^240\./, // Reserved |
| 873 | + /^255\./, // Broadcast |
| 874 | + /^::1$/, // IPv6 loopback |
| 875 | + /^fe80:/i, // IPv6 link-local |
| 876 | + /^fc00:/i, // IPv6 unique local |
| 877 | + /^fd00:/i, // IPv6 unique local |
| 878 | + /^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped IPv6 |
| 879 | + ] |
| 880 | + return patterns.some((pattern) => pattern.test(ip)) |
| 881 | +} |
| 882 | + |
| 883 | +/** |
| 884 | + * Result type for async URL validation with resolved IP |
| 885 | + */ |
| 886 | +export interface AsyncValidationResult extends ValidationResult { |
| 887 | + resolvedIP?: string |
| 888 | + originalHostname?: string |
| 889 | +} |
| 890 | + |
| 891 | +/** |
| 892 | + * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding |
| 893 | + * |
| 894 | + * This function: |
| 895 | + * 1. Performs basic URL validation (protocol, format) |
| 896 | + * 2. Resolves the hostname to an IP address |
| 897 | + * 3. Validates the resolved IP is not private/reserved |
| 898 | + * 4. Returns the resolved IP for use in the actual request |
| 899 | + * |
| 900 | + * @param url - The URL to validate |
| 901 | + * @param paramName - Name of the parameter for error messages |
| 902 | + * @returns AsyncValidationResult with resolved IP for DNS pinning |
| 903 | + */ |
| 904 | +export async function validateUrlWithDNS( |
| 905 | + url: string | null | undefined, |
| 906 | + paramName = 'url' |
| 907 | +): Promise<AsyncValidationResult> { |
| 908 | + const basicValidation = validateExternalUrl(url, paramName) |
| 909 | + if (!basicValidation.isValid) { |
| 910 | + return basicValidation |
| 911 | + } |
| 912 | + |
| 913 | + const parsedUrl = new URL(url!) |
| 914 | + const hostname = parsedUrl.hostname |
| 915 | + |
| 916 | + try { |
| 917 | + const { address } = await dns.lookup(hostname) |
| 918 | + |
| 919 | + if (isPrivateOrReservedIP(address)) { |
| 920 | + logger.warn('URL resolves to blocked IP address', { |
| 921 | + paramName, |
| 922 | + hostname, |
| 923 | + resolvedIP: address, |
| 924 | + }) |
| 925 | + return { |
| 926 | + isValid: false, |
| 927 | + error: `${paramName} resolves to a blocked IP address`, |
| 928 | + } |
| 929 | + } |
| 930 | + |
| 931 | + return { |
| 932 | + isValid: true, |
| 933 | + resolvedIP: address, |
| 934 | + originalHostname: hostname, |
| 935 | + } |
| 936 | + } catch (error) { |
| 937 | + logger.warn('DNS lookup failed for URL', { |
| 938 | + paramName, |
| 939 | + hostname, |
| 940 | + error: error instanceof Error ? error.message : String(error), |
| 941 | + }) |
| 942 | + return { |
| 943 | + isValid: false, |
| 944 | + error: `${paramName} hostname could not be resolved`, |
| 945 | + } |
| 946 | + } |
| 947 | +} |
| 948 | + |
| 949 | +/** |
| 950 | + * Creates a fetch URL that uses a resolved IP address to prevent DNS rebinding |
| 951 | + * |
| 952 | + * @param originalUrl - The original URL |
| 953 | + * @param resolvedIP - The resolved IP address to use |
| 954 | + * @returns The URL with IP substituted for hostname |
| 955 | + */ |
| 956 | +export function createPinnedUrl(originalUrl: string, resolvedIP: string): string { |
| 957 | + const parsed = new URL(originalUrl) |
| 958 | + const port = parsed.port ? `:${parsed.port}` : '' |
| 959 | + return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}` |
| 960 | +} |
0 commit comments