120 lines
2.6 KiB
TypeScript
120 lines
2.6 KiB
TypeScript
import { lookup } from 'dns/promises';
|
|
|
|
const MAX_REDIRECTS = 5;
|
|
|
|
function isPrivateIp(ip: string): boolean {
|
|
if (
|
|
ip === '127.0.0.1' ||
|
|
ip === '::1' ||
|
|
ip === '0.0.0.0' ||
|
|
ip === '::'
|
|
)
|
|
return true;
|
|
|
|
// 10.x.x.x
|
|
if (ip.startsWith('10.'))
|
|
return true;
|
|
|
|
// 172.16.0.0 - 172.31.255.255
|
|
if (ip.startsWith('172.')) {
|
|
const second = parseInt(ip.split('.')[1], 10);
|
|
|
|
if (second >= 16 && second <= 31)
|
|
return true;
|
|
}
|
|
|
|
// 192.168.x.x
|
|
if (ip.startsWith('192.168.'))
|
|
return true;
|
|
|
|
// 169.254.x.x (link-local, AWS metadata)
|
|
if (ip.startsWith('169.254.'))
|
|
return true;
|
|
|
|
// IPv6 private ranges (fc00::/7, fe80::/10)
|
|
const lower = ip.toLowerCase();
|
|
|
|
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
|
let hostname: string;
|
|
|
|
try {
|
|
hostname = new URL(url).hostname;
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
// Block obvious private hostnames
|
|
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
|
return false;
|
|
|
|
// If hostname is already an IP literal, check it directly
|
|
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
|
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
|
|
|
try {
|
|
const { address } = await lookup(hostname);
|
|
|
|
return !isPrivateIp(address);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export interface SafeFetchOptions {
|
|
signal?: AbortSignal;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Fetches a URL while following redirects safely, validating each
|
|
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
|
*
|
|
* The caller must validate the initial URL with `resolveAndValidateHost`
|
|
* before calling this function.
|
|
*/
|
|
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
|
let currentUrl = url;
|
|
let response: Response | undefined;
|
|
|
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
response = await fetch(currentUrl, {
|
|
redirect: 'manual',
|
|
signal: options.signal,
|
|
headers: options.headers
|
|
});
|
|
|
|
const location = response.headers.get('location');
|
|
|
|
if (response.status >= 300 && response.status < 400 && location) {
|
|
let nextUrl: string;
|
|
|
|
try {
|
|
nextUrl = new URL(location, currentUrl).href;
|
|
} catch {
|
|
break;
|
|
}
|
|
|
|
if (!/^https?:\/\//i.test(nextUrl))
|
|
break;
|
|
|
|
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
|
|
|
if (!redirectAllowed)
|
|
break;
|
|
|
|
currentUrl = nextUrl;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return response;
|
|
}
|