sync: update from private repo (e62f5c7)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 01:52:48 +00:00
parent 000a2474aa
commit d061ad08d8
237 changed files with 8441 additions and 5549 deletions
+28 -4
View File
@@ -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;
}
+62 -10
View File
@@ -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();
}