This commit is contained in:
@@ -1195,11 +1195,35 @@ async function setupRouteInterception(page: Page, allowedHosts: string[], worksp
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS resolve and check for private IPs
|
||||
// DNS resolve and check for private IPs. Resolve ALL addresses (not just
|
||||
// the first) and block if ANY resolves to a private/forbidden range — a
|
||||
// rebinding host can return one public + one metadata IP, and the browser
|
||||
// may connect to either.
|
||||
//
|
||||
// KNOWN RESIDUAL RISK — time-delayed DNS rebinding TOCTOU (#467):
|
||||
// After route.continue(), Chromium performs its OWN DNS lookup to open the
|
||||
// socket. A host that rebinds between our lookup here and Chromium's
|
||||
// (e.g. with a sub-second TTL) can therefore still steer the connection to
|
||||
// a private IP. Unlike pinnedFetch (src/net/ssrf-strict.ts), which dials
|
||||
// the exact validated address and pins the socket, Playwright's route
|
||||
// interception offers no way to pin the connection target — this gap is
|
||||
// inherent to the route-interception approach. The pinnedFetch paths
|
||||
// (WebFetch / DownloadFile / MCP) are NOT affected. Closing this fully
|
||||
// would require a pinning forward proxy, which is intentionally not
|
||||
// implemented; the multi-record validation above is the accepted
|
||||
// best-effort mitigation.
|
||||
try {
|
||||
const result = await dns.promises.lookup(hostname);
|
||||
if (isPrivateIPv4(result.address) || isPrivateIPv6(result.address)) {
|
||||
logger.warn(`[browser] SSRF blocked: ${hostname} -> ${result.address}`);
|
||||
const results = await dns.promises.lookup(hostname, { all: true });
|
||||
if (results.length === 0) {
|
||||
logger.warn(`[browser] SSRF blocked: ${hostname} resolved to no addresses`);
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
const forbidden = results.find(
|
||||
(r) => isPrivateIPv4(r.address) || isPrivateIPv6(r.address),
|
||||
);
|
||||
if (forbidden) {
|
||||
logger.warn(`[browser] SSRF blocked: ${hostname} -> ${forbidden.address}`);
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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