fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4

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>
This commit is contained in:
2026-08-04 15:57:22 +05:30
co-authored by Claude Opus 5
parent 72e4d3eceb
commit c64434a131
10 changed files with 369 additions and 45 deletions
+64 -16
View File
@@ -10,6 +10,14 @@ export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
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[]>;
}
@@ -28,25 +36,40 @@ function isPrivateIpv4(address: string): boolean {
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 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
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 === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
// 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")
);
}
@@ -61,8 +84,9 @@ async function defaultResolver(hostname: string): Promise<string[]> {
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
}
async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<void> {
if (options.blockPrivateNetworks === false) return;
/** 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.`);
@@ -75,6 +99,22 @@ async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<vo
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(
@@ -100,6 +140,7 @@ export async function safeFetch(
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;
@@ -114,23 +155,30 @@ export async function safeFetch(
allowRelative: false,
allowedProtocols: options.allowedProtocols ?? ["https:"],
});
let previousOrigin = current.origin;
// 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++) {
await assertPublicHost(current, options);
const addresses = await resolvePublicHost(current, options);
const requestInit: RequestInit = { ...init };
if (!options.forwardSensitiveHeaders && current.origin !== previousOrigin) {
const headers = new Headers(init.headers);
const headers = new Headers(init.headers);
if (!options.forwardSensitiveHeaders && current.origin !== credentialOrigin) {
headers.delete("authorization");
headers.delete("cookie");
headers.delete("proxy-authorization");
requestInit.headers = headers;
}
const response = await fetch(current, requestInit);
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);
}
previousOrigin = current.origin;
current = validateUrl(new URL(response.headers.get("location")!, current), {
...options,
allowRelative: false,
+15 -2
View File
@@ -12,6 +12,17 @@ export interface SafeUrlPolicy {
const DEFAULT_PROTOCOLS = ["http:", "https:"];
const RELATIVE_PREFIX = /^(?:\.{0,2}\/|\/|\?|#)/;
/**
* `//evil.com` (and the `/\evil.com` spelling browsers normalise to it) reads
* like a same-site path but navigates cross-origin. It must never be handed
* back verbatim, or the host checks above are bypassed entirely.
*/
function isProtocolRelative(raw: string): boolean {
return /^[/\\]{2}/.test(raw);
}
function hasAsciiControlOrSpace(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
@@ -39,7 +50,7 @@ export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): UR
);
}
const isRelative = /^(?:\.{0,2}\/|\/|\?|#)/.test(raw);
const isRelative = RELATIVE_PREFIX.test(raw);
if (isRelative && policy.allowRelative === false) {
throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed.");
}
@@ -93,7 +104,9 @@ export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string
try {
const raw = String(value ?? "");
const url = validateUrl(raw, policy);
if (/^(?:\.{0,2}\/|\/|\?|#)/.test(raw)) return raw;
// Genuinely relative input round-trips unchanged; protocol-relative input
// is resolved so the returned string carries the host the policy approved.
if (RELATIVE_PREFIX.test(raw) && !isProtocolRelative(raw)) return raw;
return url.toString();
} catch {
return "about:blank";
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, test } from "bun:test";
import { isPrivateAddress, safeFetch, sanitizeUrl } from "../src/index.ts";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
/** Capture what each hop actually receives, and script the redirect chain. */
function stubFetch(chain: (string | null)[]) {
const seen: { url: string; host: string | null; authorization: string | null }[] = [];
let hop = 0;
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seen.push({
url: String(input),
host: headers.get("host"),
authorization: headers.get("authorization"),
});
const location = chain[hop++];
return location
? new Response(null, { status: 302, headers: { location } })
: new Response("done", { status: 200 });
}) as typeof fetch;
return seen;
}
describe("safeFetch redirect credential handling", () => {
test("does not re-attach credentials after leaving the original origin", async () => {
const seen = stubFetch(["https://b.example/x", "https://b.example/final", null]);
await safeFetch("https://a.example/start", {
headers: { authorization: "Bearer SECRET", cookie: "sid=1" },
resolver: async () => ["93.184.216.34"],
});
expect(seen).toHaveLength(3);
expect(seen[0]!.authorization).toBe("Bearer SECRET");
// Both hops on b.example are off-origin, including the b→b one.
expect(seen[1]!.authorization).toBeNull();
expect(seen[2]!.authorization).toBeNull();
});
test("keeps credentials across same-origin redirects", async () => {
const seen = stubFetch(["https://a.example/next", null]);
await safeFetch("https://a.example/start", {
headers: { authorization: "Bearer SECRET" },
resolver: async () => ["93.184.216.34"],
});
expect(seen[1]!.authorization).toBe("Bearer SECRET");
});
test("forwardSensitiveHeaders opts back in", async () => {
const seen = stubFetch(["https://b.example/x", null]);
await safeFetch("https://a.example/start", {
headers: { authorization: "Bearer SECRET" },
forwardSensitiveHeaders: true,
resolver: async () => ["93.184.216.34"],
});
expect(seen[1]!.authorization).toBe("Bearer SECRET");
});
});
describe("safeFetch DNS pinning", () => {
test("connects to the validated address and preserves the Host header", async () => {
const seen = stubFetch([null]);
await safeFetch("https://rebind.example/data", {
resolver: async () => ["93.184.216.34"],
});
// The connection targets the address we checked, so a second DNS answer
// cannot redirect it at an internal host.
expect(seen[0]!.url).toBe("https://93.184.216.34/data");
expect(seen[0]!.host).toBe("rebind.example");
});
test("pinDns:false restores hostname dialling", async () => {
const seen = stubFetch([null]);
await safeFetch("https://rebind.example/data", {
pinDns: false,
resolver: async () => ["93.184.216.34"],
});
expect(seen[0]!.url).toBe("https://rebind.example/data");
});
test("still rejects hosts that resolve into private space", async () => {
stubFetch([null]);
await expect(
safeFetch("https://rebind.example/data", { resolver: async () => ["169.254.169.254"] }),
).rejects.toThrow(/blocked address/);
});
});
describe("isPrivateAddress coverage", () => {
test("catches non-canonical IPv4-mapped and site-local IPv6", () => {
expect(isPrivateAddress("0:0:0:0:0:ffff:127.0.0.1")).toBe(true);
expect(isPrivateAddress("::ffff:7f00:1")).toBe(true);
expect(isPrivateAddress("fec0::1")).toBe(true);
expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
expect(isPrivateAddress("198.18.0.1")).toBe(true);
expect(isPrivateAddress("192.0.0.192")).toBe(true);
});
test("leaves public addresses alone", () => {
expect(isPrivateAddress("93.184.216.34")).toBe(false);
expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false);
});
});
describe("sanitizeUrl protocol-relative handling", () => {
test("does not hand back a protocol-relative URL verbatim", () => {
// "//evil.com" in an href navigates cross-origin; returning it unchanged
// would bypass every host check validateUrl just performed.
expect(sanitizeUrl("//evil.com")).toBe("http://evil.com/");
expect(sanitizeUrl("//evil.com/path?a=b")).toBe("http://evil.com/path?a=b");
// Backslash spellings resolve the same way rather than passing through.
expect(sanitizeUrl("\\\\evil.com")).toBe("http://evil.com/");
expect(sanitizeUrl("/\\evil.com")).toBe("http://evil.com/");
});
test("honours host policy for protocol-relative input", () => {
expect(sanitizeUrl("//evil.com", { allowedHosts: ["good.com"] })).toBe("about:blank");
expect(sanitizeUrl("//good.com/x", { allowedHosts: ["good.com"] })).toBe("http://good.com/x");
});
test("genuine relative paths round-trip unchanged", () => {
expect(sanitizeUrl("/safe/path")).toBe("/safe/path");
expect(sanitizeUrl("./rel")).toBe("./rel");
expect(sanitizeUrl("?q=1")).toBe("?q=1");
expect(sanitizeUrl("#frag")).toBe("#frag");
});
});