|
1 | 1 | import dns from 'dns/promises' |
| 2 | +import http from 'http' |
| 3 | +import https from 'https' |
2 | 4 | import { createLogger } from '@sim/logger' |
3 | 5 |
|
4 | 6 | const logger = createLogger('InputValidation') |
@@ -898,6 +900,139 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string |
898 | 900 | return `${parsed.protocol}//${host}${port}${parsed.pathname}${parsed.search}` |
899 | 901 | } |
900 | 902 |
|
| 903 | +export interface SecureFetchOptions { |
| 904 | + method?: string |
| 905 | + headers?: Record<string, string> |
| 906 | + body?: string |
| 907 | + timeout?: number |
| 908 | +} |
| 909 | + |
| 910 | +export class SecureFetchHeaders { |
| 911 | + private headers: Map<string, string> |
| 912 | + |
| 913 | + constructor(headers: Record<string, string>) { |
| 914 | + this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) |
| 915 | + } |
| 916 | + |
| 917 | + get(name: string): string | null { |
| 918 | + return this.headers.get(name.toLowerCase()) ?? null |
| 919 | + } |
| 920 | + |
| 921 | + toRecord(): Record<string, string> { |
| 922 | + const record: Record<string, string> = {} |
| 923 | + for (const [key, value] of this.headers) { |
| 924 | + record[key] = value |
| 925 | + } |
| 926 | + return record |
| 927 | + } |
| 928 | + |
| 929 | + [Symbol.iterator]() { |
| 930 | + return this.headers.entries() |
| 931 | + } |
| 932 | +} |
| 933 | + |
| 934 | +export interface SecureFetchResponse { |
| 935 | + ok: boolean |
| 936 | + status: number |
| 937 | + statusText: string |
| 938 | + headers: SecureFetchHeaders |
| 939 | + text: () => Promise<string> |
| 940 | + json: () => Promise<unknown> |
| 941 | + arrayBuffer: () => Promise<ArrayBuffer> |
| 942 | +} |
| 943 | + |
| 944 | +/** |
| 945 | + * Performs a fetch with IP pinning to prevent DNS rebinding attacks. |
| 946 | + * Uses the pre-resolved IP address while preserving the original hostname for TLS SNI. |
| 947 | + */ |
| 948 | +export function secureFetchWithPinnedIP( |
| 949 | + url: string, |
| 950 | + resolvedIP: string, |
| 951 | + options: SecureFetchOptions = {} |
| 952 | +): Promise<SecureFetchResponse> { |
| 953 | + return new Promise((resolve, reject) => { |
| 954 | + const parsed = new URL(url) |
| 955 | + const isHttps = parsed.protocol === 'https:' |
| 956 | + const defaultPort = isHttps ? 443 : 80 |
| 957 | + const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort |
| 958 | + |
| 959 | + const isIPv6 = resolvedIP.includes(':') |
| 960 | + const family = isIPv6 ? 6 : 4 |
| 961 | + |
| 962 | + const agentOptions = { |
| 963 | + lookup: ( |
| 964 | + _hostname: string, |
| 965 | + _options: unknown, |
| 966 | + callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void |
| 967 | + ) => { |
| 968 | + callback(null, resolvedIP, family) |
| 969 | + }, |
| 970 | + } |
| 971 | + |
| 972 | + const agent = isHttps |
| 973 | + ? new https.Agent(agentOptions as https.AgentOptions) |
| 974 | + : new http.Agent(agentOptions as http.AgentOptions) |
| 975 | + |
| 976 | + const requestOptions: http.RequestOptions = { |
| 977 | + hostname: parsed.hostname, |
| 978 | + port, |
| 979 | + path: parsed.pathname + parsed.search, |
| 980 | + method: options.method || 'GET', |
| 981 | + headers: options.headers || {}, |
| 982 | + agent, |
| 983 | + timeout: options.timeout || 30000, |
| 984 | + } |
| 985 | + |
| 986 | + const protocol = isHttps ? https : http |
| 987 | + const req = protocol.request(requestOptions, (res) => { |
| 988 | + const chunks: Buffer[] = [] |
| 989 | + |
| 990 | + res.on('data', (chunk: Buffer) => chunks.push(chunk)) |
| 991 | + res.on('end', () => { |
| 992 | + const bodyBuffer = Buffer.concat(chunks) |
| 993 | + const body = bodyBuffer.toString('utf-8') |
| 994 | + const headersRecord: Record<string, string> = {} |
| 995 | + for (const [key, value] of Object.entries(res.headers)) { |
| 996 | + if (typeof value === 'string') { |
| 997 | + headersRecord[key.toLowerCase()] = value |
| 998 | + } else if (Array.isArray(value)) { |
| 999 | + headersRecord[key.toLowerCase()] = value.join(', ') |
| 1000 | + } |
| 1001 | + } |
| 1002 | + |
| 1003 | + resolve({ |
| 1004 | + ok: res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300, |
| 1005 | + status: res.statusCode || 0, |
| 1006 | + statusText: res.statusMessage || '', |
| 1007 | + headers: new SecureFetchHeaders(headersRecord), |
| 1008 | + text: async () => body, |
| 1009 | + json: async () => JSON.parse(body), |
| 1010 | + arrayBuffer: async () => |
| 1011 | + bodyBuffer.buffer.slice( |
| 1012 | + bodyBuffer.byteOffset, |
| 1013 | + bodyBuffer.byteOffset + bodyBuffer.byteLength |
| 1014 | + ), |
| 1015 | + }) |
| 1016 | + }) |
| 1017 | + }) |
| 1018 | + |
| 1019 | + req.on('error', (error) => { |
| 1020 | + reject(error) |
| 1021 | + }) |
| 1022 | + |
| 1023 | + req.on('timeout', () => { |
| 1024 | + req.destroy() |
| 1025 | + reject(new Error('Request timeout')) |
| 1026 | + }) |
| 1027 | + |
| 1028 | + if (options.body) { |
| 1029 | + req.write(options.body) |
| 1030 | + } |
| 1031 | + |
| 1032 | + req.end() |
| 1033 | + }) |
| 1034 | +} |
| 1035 | + |
901 | 1036 | /** |
902 | 1037 | * Validates an Airtable ID (base, table, or webhook ID) |
903 | 1038 | * |
|
0 commit comments