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>
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
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");
|
|
});
|
|
});
|