Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.
security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
followed a cross-origin hop (a -> b -> b), handing credentials to the second
host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
resolve again, so a low-TTL record could answer public for the check and
private for the connection. Pin the connection to the validated address,
preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
and 192.0.0/24.
security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
bypassing the host checks it had just run; in an href that navigates
cross-origin. Resolve protocol-relative input instead.
dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
by timing. Extracted as verifyBasicAuth so it is testable.
authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
did not grant "post:comment:delete". Match at every depth.
uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
type, and reject ".." path segments.
package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.
Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { isIP } from "node:net";
|
|
import { SecurityError } from "./errors.ts";
|
|
import { validateUrl, type SafeUrlPolicy } from "./url.ts";
|
|
|
|
export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
|
|
timeoutMs?: number;
|
|
maxRedirects?: number;
|
|
maxResponseBytes?: number;
|
|
blockPrivateNetworks?: boolean;
|
|
/** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */
|
|
forwardSensitiveHeaders?: boolean;
|
|
/**
|
|
* Connect to the address this module validated instead of re-resolving the
|
|
* hostname inside `fetch`. Without it the private-network guard is advisory
|
|
* only: a low-TTL DNS record can answer with a public address for our lookup
|
|
* and a private one for the connection (DNS rebinding). Defaults to true
|
|
* whenever `blockPrivateNetworks` is on.
|
|
*/
|
|
pinDns?: boolean;
|
|
resolver?: (hostname: string) => Promise<string[]>;
|
|
}
|
|
|
|
function isPrivateIpv4(address: string): boolean {
|
|
const parts = address.split(".").map(Number);
|
|
if (
|
|
parts.length !== 4 ||
|
|
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
|
) {
|
|
return false;
|
|
}
|
|
const [a, b] = parts;
|
|
return (
|
|
a === 0 ||
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 169 && b === 254) ||
|
|
(a === 172 && b! >= 16 && b! <= 31) ||
|
|
(a === 192 && b === 0) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 198 && b! >= 18 && b! <= 19) ||
|
|
(a === 100 && b! >= 64 && b! <= 127) ||
|
|
a! >= 224
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Any IPv4-mapped form, not just the compact `::ffff:1.2.3.4` spelling.
|
|
* `0:0:0:0:0:ffff:127.0.0.1` and `::ffff:7f00:1` address loopback just as well.
|
|
*/
|
|
function mappedIpv4Of(normalized: string): string | null {
|
|
const dotted = /^(?:0*:)*0*ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
|
|
if (dotted) return dotted;
|
|
const hex = /^(?:0*:)*0*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
|
|
if (!hex) return null;
|
|
const high = Number.parseInt(hex[1]!, 16);
|
|
const low = Number.parseInt(hex[2]!, 16);
|
|
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
}
|
|
|
|
function isPrivateIpv6(address: string): boolean {
|
|
const normalized = address.toLowerCase().split("%")[0]!;
|
|
const mappedIpv4 = mappedIpv4Of(normalized);
|
|
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
|
|
if (/^(?:0*:)+0*1$/.test(normalized)) return true; // ::1 in any expansion
|
|
if (/^(?:0*:)*0*$/.test(normalized)) return true; // :: / all-zero
|
|
return (
|
|
normalized.startsWith("fc") ||
|
|
normalized.startsWith("fd") ||
|
|
// fe80::/10 link-local through fec0::/10 site-local: every fe8-febf plus
|
|
// the deprecated-but-still-routable fec0-feff site-local block.
|
|
/^fe[89abcdef]/.test(normalized) ||
|
|
normalized.startsWith("ff")
|
|
);
|
|
}
|
|
|
|
export function isPrivateAddress(address: string): boolean {
|
|
const version = isIP(address);
|
|
return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false;
|
|
}
|
|
|
|
async function defaultResolver(hostname: string): Promise<string[]> {
|
|
if (isIP(hostname)) return [hostname];
|
|
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
|
|
}
|
|
|
|
/** Resolve a host and reject it unless every answer is a public address. */
|
|
async function resolvePublicHost(url: URL, options: SafeFetchOptions): Promise<string[]> {
|
|
if (options.blockPrivateNetworks === false) return [];
|
|
const addresses = await (options.resolver ?? defaultResolver)(url.hostname);
|
|
if (!addresses.length) {
|
|
throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`);
|
|
}
|
|
const blocked = addresses.find(isPrivateAddress);
|
|
if (blocked) {
|
|
throw new SecurityError(
|
|
"WRN-SEC-SSRF-PRIVATE",
|
|
`Host '${url.hostname}' resolves to blocked address '${blocked}'.`,
|
|
403,
|
|
);
|
|
}
|
|
return addresses;
|
|
}
|
|
|
|
/**
|
|
* Rewrite the request to dial the address we just validated, keeping `Host`
|
|
* (and TLS SNI/certificate verification) pointed at the original hostname.
|
|
* This is what makes the private-network guard binding rather than advisory.
|
|
*/
|
|
function pinToAddress(url: URL, address: string, init: RequestInit, headers: Headers): URL {
|
|
const pinned = new URL(url);
|
|
pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
|
|
headers.set("host", url.host);
|
|
if (url.protocol === "https:") {
|
|
(init as { tls?: { serverName: string } }).tls = { serverName: url.hostname };
|
|
}
|
|
return pinned;
|
|
}
|
|
|
|
export async function safeFetch(
|
|
input: string | URL,
|
|
options: SafeFetchOptions = {},
|
|
): Promise<Response> {
|
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
const maxRedirects = options.maxRedirects ?? 3;
|
|
const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024;
|
|
const controller = new AbortController();
|
|
const externalSignal = options.signal;
|
|
const abort = () => controller.abort(externalSignal?.reason);
|
|
externalSignal?.addEventListener("abort", abort, { once: true });
|
|
const timer = setTimeout(
|
|
() => controller.abort(new Error("WRNexus safeFetch timeout")),
|
|
timeoutMs,
|
|
);
|
|
|
|
const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" };
|
|
delete (init as Record<string, unknown>).timeoutMs;
|
|
delete (init as Record<string, unknown>).maxRedirects;
|
|
delete (init as Record<string, unknown>).maxResponseBytes;
|
|
delete (init as Record<string, unknown>).blockPrivateNetworks;
|
|
delete (init as Record<string, unknown>).resolver;
|
|
delete (init as Record<string, unknown>).forwardSensitiveHeaders;
|
|
delete (init as Record<string, unknown>).pinDns;
|
|
delete (init as Record<string, unknown>).base;
|
|
delete (init as Record<string, unknown>).allowRelative;
|
|
delete (init as Record<string, unknown>).allowedProtocols;
|
|
delete (init as Record<string, unknown>).allowedHosts;
|
|
delete (init as Record<string, unknown>).blockedHosts;
|
|
delete (init as Record<string, unknown>).allowCredentials;
|
|
delete (init as Record<string, unknown>).allowDataImages;
|
|
|
|
try {
|
|
let current = validateUrl(input, {
|
|
...options,
|
|
allowRelative: false,
|
|
allowedProtocols: options.allowedProtocols ?? ["https:"],
|
|
});
|
|
// Compare against where the caller's credentials were meant to go, not
|
|
// against the previous hop: a→b→b must not re-attach them on the second
|
|
// b request just because that hop did not change origin.
|
|
const credentialOrigin = current.origin;
|
|
for (let redirect = 0; ; redirect++) {
|
|
const addresses = await resolvePublicHost(current, options);
|
|
const requestInit: RequestInit = { ...init };
|
|
const headers = new Headers(init.headers);
|
|
if (!options.forwardSensitiveHeaders && current.origin !== credentialOrigin) {
|
|
headers.delete("authorization");
|
|
headers.delete("cookie");
|
|
headers.delete("proxy-authorization");
|
|
}
|
|
const pinDns = options.pinDns ?? options.blockPrivateNetworks !== false;
|
|
const target =
|
|
pinDns && addresses.length
|
|
? pinToAddress(current, addresses[0]!, requestInit, headers)
|
|
: current;
|
|
requestInit.headers = headers;
|
|
const response = await fetch(target, requestInit);
|
|
if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
|
|
if (redirect >= maxRedirects) {
|
|
throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502);
|
|
}
|
|
current = validateUrl(new URL(response.headers.get("location")!, current), {
|
|
...options,
|
|
allowRelative: false,
|
|
allowedProtocols: options.allowedProtocols ?? ["https:"],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const contentLength = Number(response.headers.get("content-length") ?? "0");
|
|
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
|
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
|
|
}
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
if (bytes.byteLength > maxResponseBytes) {
|
|
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
|
|
}
|
|
return new Response(bytes, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
externalSignal?.removeEventListener("abort", abort);
|
|
}
|
|
}
|