This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import * as dns from 'dns';
|
||||
import { isIP } from 'node:net';
|
||||
import { isPrivateOrForbidden } from '../../../net/ssrf-strict.js';
|
||||
import { isPrivateOrForbidden, pinnedFetch } from '../../../net/ssrf-strict.js';
|
||||
|
||||
// These delegate to the hardened range check in src/net/ssrf-strict.ts so that
|
||||
// WebFetch / DownloadFile / BrowseWeb get the same coverage as MCP and SSH:
|
||||
@@ -27,14 +27,48 @@ export function isHostAllowed(hostname: string, allowedHosts: string[]): boolean
|
||||
* Resolves ALL addresses for the hostname (not just the first) and rejects if
|
||||
* any resolves to a private/forbidden range. An explicit allowlist entry
|
||||
* bypasses the check (used for trusted internal hosts).
|
||||
*
|
||||
* Same policy as `resolvePinnedTarget` (which it delegates to), for callers
|
||||
* that cannot pin the connection (e.g. Playwright-driven browsing).
|
||||
*/
|
||||
export async function checkSSRF(hostname: string, allowedHosts: string[]): Promise<void> {
|
||||
await resolvePinnedTarget(hostname, allowedHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hostname, enforce SSRF policy on ALL addresses, and return the IP
|
||||
* the connection must be pinned to.
|
||||
*
|
||||
* Same policy as `checkSSRF` (localhost block, allowlist bypass, every
|
||||
* resolved address must pass `isPrivateOrForbidden`) but it also hands back
|
||||
* the address to pin so the caller can connect to the exact IP it validated.
|
||||
*
|
||||
* Returns `null` when the host is on the allowlist — those go through a normal
|
||||
* (non-pinned) fetch so trusted internal hosts keep working. For a literal IP
|
||||
* the literal itself is the pin (no DNS round-trip).
|
||||
*/
|
||||
async function resolvePinnedTarget(
|
||||
hostname: string,
|
||||
allowedHosts: string[],
|
||||
): Promise<{ pinnedIp: string; family: 4 | 6 } | null> {
|
||||
if (hostname === 'localhost' && !isHostAllowed(hostname, allowedHosts)) {
|
||||
throw new Error(`SSRF blocked: hostname "localhost" is not allowed`);
|
||||
}
|
||||
if (isHostAllowed(hostname, allowedHosts)) {
|
||||
return;
|
||||
// Trusted host: skip pinning, let fetch resolve it normally.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Literal IP: no DNS query, pin the literal directly.
|
||||
const literalFamily = isIP(hostname);
|
||||
if (literalFamily === 4 || literalFamily === 6) {
|
||||
const fam = literalFamily as 4 | 6;
|
||||
if (isPrivateOrForbidden(hostname, fam)) {
|
||||
throw new Error(`SSRF blocked: "${hostname}" is a forbidden IP`);
|
||||
}
|
||||
return { pinnedIp: hostname, family: fam };
|
||||
}
|
||||
|
||||
let addrs: Array<{ address: string; family: number }>;
|
||||
try {
|
||||
addrs = await dns.promises.lookup(hostname, { all: true });
|
||||
@@ -49,21 +83,26 @@ export async function checkSSRF(hostname: string, allowedHosts: string[]): Promi
|
||||
throw new Error(`SSRF blocked: "${hostname}" resolves to forbidden IP "${a.address}"`);
|
||||
}
|
||||
}
|
||||
return { pinnedIp: addrs[0].address, family: addrs[0].family as 4 | 6 };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch that re-validates every redirect hop.
|
||||
* SSRF-safe fetch that re-validates every redirect hop AND pins the connection
|
||||
* to the validated IP (DNS-rebinding defense).
|
||||
*
|
||||
* `fetch`'s default redirect following re-resolves DNS and would happily
|
||||
* follow a 30x to http://169.254.169.254/ (cloud metadata) or an internal
|
||||
* host. This follows redirects manually and runs `checkSSRF` against each
|
||||
* host. This follows redirects manually and runs the SSRF policy against each
|
||||
* Location before requesting it, so a public URL cannot bounce the request
|
||||
* into a private destination.
|
||||
*
|
||||
* Residual: this does not pin the resolved IP, so a sub-second DNS-rebinding
|
||||
* attacker can still race the validation lookup against the connection lookup.
|
||||
* Full pinning (as in src/net/ssrf-strict.ts#pinnedFetch) is the follow-up;
|
||||
* this closes the redirect path, which is the practically exploitable one.
|
||||
* It also closes the TOCTOU rebinding gap: instead of validating the host with
|
||||
* one DNS lookup and then letting `fetch` re-resolve (a sub-second rebind can
|
||||
* return a public IP to the check and a private/metadata IP to the connection),
|
||||
* each hop resolves once, validates every returned address, and connects to the
|
||||
* exact validated IP via `pinnedFetch` (custom undici `connect.lookup`, real
|
||||
* Host header preserved from the URL). Allowlisted hosts bypass pinning and use
|
||||
* a normal fetch.
|
||||
*/
|
||||
export async function ssrfSafeFetch(
|
||||
url: string,
|
||||
@@ -74,12 +113,25 @@ export async function ssrfSafeFetch(
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= maxRedirects; hop++) {
|
||||
const parsed = new URL(current);
|
||||
await checkSSRF(parsed.hostname, allowedHosts);
|
||||
const res = await fetch(current, { ...init, redirect: 'manual' });
|
||||
const pin = await resolvePinnedTarget(parsed.hostname, allowedHosts);
|
||||
const res = pin
|
||||
? await pinnedFetch(current, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
pinnedIp: pin.pinnedIp,
|
||||
family: pin.family,
|
||||
})
|
||||
: await fetch(current, { ...init, redirect: 'manual' });
|
||||
const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
||||
if (!location) {
|
||||
return res;
|
||||
}
|
||||
// Discard the abandoned redirect hop's body. Without this the hop's
|
||||
// connection stays open, and pinnedFetch's per-call Agent (which closes
|
||||
// only once its body is consumed or cancelled) would leak per hop.
|
||||
if (res.body && !res.bodyUsed) {
|
||||
res.body.cancel().catch(() => {});
|
||||
}
|
||||
// Resolve relative redirects against the current URL.
|
||||
current = new URL(location, current).toString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user