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
+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();
}