fix(capture): share the render host policy and block localhost aliases (#3844)

This commit is contained in:
James Russo
2026-09-10 07:59:30 -04:00
committed by GitHub
parent a7e2e3853a
commit 96637aeb1c
5 changed files with 65 additions and 50 deletions
@@ -22,6 +22,11 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
"http://172.16.0.1/",
"http://192.168.1.1/",
"http://169.254.169.254/", // cloud metadata
"http://100.64.0.1/",
"http://192.0.0.1/",
"http://198.18.0.1/",
"http://224.0.0.1/",
"http://240.0.0.1/",
]) {
expect(isPrivateUrl(u), u).toBe(true);
}
@@ -38,6 +43,8 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
"http://[::ffff:169.254.169.254]/", // IPv4-mapped metadata
"http://[fd00::1]/", // unique-local fc00::/7
"http://[fe80::1]/", // link-local fe80::/10
"http://[fec0::1]/",
"http://[ff02::1]/",
]) {
expect(isPrivateUrl(u), u).toBe(true);
}
@@ -56,6 +63,8 @@ describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
it("allows ordinary public URLs", () => {
expect(isPrivateUrl("https://example.com/logo.png")).toBe(false);
expect(isPrivateUrl("http://example.com./logo.png")).toBe(false);
expect(isPrivateUrl("http://notlocalhost.example/logo.png")).toBe(false);
expect(isPrivateUrl("https://cdn.jsdelivr.net/a.svg")).toBe(false);
});
});
@@ -96,6 +105,31 @@ describe("safeFetch — re-validates the denylist on every redirect hop (securit
expect(await res?.text()).toBe("ok");
});
it("blocks localhost and internal DNS aliases initially and after redirects", async () => {
for (const host of [
"localhost.",
"foo.localhost",
"FOO.LOCALHOST.",
"db.internal.",
"svc.local.",
]) {
const target = `http://${host}/private`;
const fetchMock = vi.fn(
async () =>
new Response(null, {
status: 302,
headers: { location: target },
}),
);
vi.stubGlobal("fetch", fetchMock);
expect(isPrivateUrl(target), target).toBe(true);
expect(await safeFetch(target), target).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(await safeFetch("https://public.example/asset"), target).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
}
});
it("returns null when the initial URL is private", async () => {
const fetchMock = vi.fn(async () => new Response("ok"));
vi.stubGlobal("fetch", fetchMock);
+3 -46
View File
@@ -5,6 +5,7 @@
* resolution) as the single source of truth for images. Favicon links are passed separately.
*/
import { isBlockedNetworkHost } from "@hyperframes/engine";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, extname } from "node:path";
import { createHash } from "node:crypto";
@@ -626,61 +627,17 @@ export async function downloadAndRewriteFonts(
return { css: rewritten, drops };
}
// Reserved/loopback/private IPv4 blocks as [firstOctet, secondOctetLo, secondOctetHi].
const PRIVATE_V4_BLOCKS: ReadonlyArray<readonly [number, number, number]> = [
[0, 0, 255], // 0.0.0.0/8 (incl. 0.0.0.0, which routes to localhost)
[10, 0, 255], // 10.0.0.0/8
[127, 0, 255], // 127.0.0.0/8 loopback
[172, 16, 31], // 172.16.0.0/12
[192, 168, 168], // 192.168.0.0/16
[169, 254, 254], // 169.254.0.0/16 link-local (cloud metadata)
];
/** True for a dotted-quad IPv4 literal in a loopback/private/reserved range. */
function isPrivateIpv4(host: string): boolean {
const octets = host.split(".").map(Number);
if (octets.length !== 4) return false;
const [a, b] = octets as [number, number, number, number];
return PRIVATE_V4_BLOCKS.some(([first, lo, hi]) => a === first && b >= lo && b <= hi);
}
/** True for a bracketed IPv6 hostname in a loopback/private/reserved range. */
function isPrivateIpv6(bracketed: string): boolean {
const addr = bracketed.replace(/^\[|\]$/g, "").toLowerCase();
if (addr === "::1" || addr === "::") return true; // loopback / unspecified
const mapped = /^::ffff:(.+)$/.exec(addr); // IPv4-mapped ::ffff:a.b.c.d or ::ffff:hhhh:hhhh
if (mapped) {
const tail = mapped[1]!;
if (tail.includes(".")) return isPrivateIpv4(tail);
const hex = tail.split(":");
if (hex.length === 2) {
const n = ((parseInt(hex[0]!, 16) << 16) | parseInt(hex[1]!, 16)) >>> 0;
return isPrivateIpv4(
[(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."),
);
}
}
if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local
return false;
}
/**
* Block requests to private/internal hosts to prevent SSRF. WHATWG URL parsing
* canonicalizes alternate IPv4 encodings (decimal/octal/hex) to dotted-quad
* before we see them, so only dotted IPv4 and bracketed IPv6 literals reach the
* classifiers below.
* shared engine classifier.
*/
export function isPrivateUrl(url: string): boolean {
try {
const u = new URL(url);
if (u.protocol !== "http:" && u.protocol !== "https:") return true; // no file:, etc.
const hostname = u.hostname;
if (hostname === "localhost") return true;
if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true;
if (hostname.startsWith("[")) return isPrivateIpv6(hostname);
if (/^\d+(\.\d+){3}$/.test(hostname)) return isPrivateIpv4(hostname);
return false;
return isBlockedNetworkHost(u.hostname);
} catch {
return true; // reject unparseable URLs
}
+1
View File
@@ -301,6 +301,7 @@ export {
export {
assertPublicHttpsUrl,
isBlockedNetworkHost,
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
@@ -155,6 +155,19 @@ describe("assertPublicHttpsUrl — SSRF guard", () => {
expect(() => assertPublicHttpsUrl("http://localhost:3000/secret")).toThrow();
});
it("rejects localhost aliases and local/internal suffixes", () => {
for (const host of [
"localhost.",
"foo.localhost",
"FOO.LOCALHOST.",
"svc.local.",
"db.internal.",
]) {
expect(() => assertPublicHttpsUrl(`https://${host}/asset`), host).toThrow("private/reserved");
}
expect(() => assertPublicHttpsUrl("https://example.com./asset")).not.toThrow();
});
it("rejects RFC1918 — 10.x", () => {
expect(() => assertPublicHttpsUrl("https://10.0.0.1/secret")).toThrow("private/reserved");
expect(() => assertPublicHttpsUrl("https://10.255.255.255/secret")).toThrow("private/reserved");
+14 -4
View File
@@ -226,9 +226,19 @@ for (const [network, prefix] of [
NON_PUBLIC_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
}
function isBlockedHost(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (h === "localhost") return true;
/** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
export function isBlockedNetworkHost(hostname: string): boolean {
const h = hostname
.toLowerCase()
.replace(/\.$/, "")
.replace(/^\[|\]$/g, "");
if (
h === "localhost" ||
h.endsWith(".localhost") ||
h.endsWith(".local") ||
h.endsWith(".internal")
)
return true;
const addressType = isIP(h);
if (addressType === 0) return false;
return addressType === 4
@@ -251,7 +261,7 @@ export function assertPublicHttpsUrl(url: string): void {
if (parsed.protocol !== "https:") {
throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions`);
}
if (isBlockedHost(parsed.hostname)) {
if (isBlockedNetworkHost(parsed.hostname)) {
throw new Error("[URLDownloader] URL targets a private/reserved address and is not permitted");
}
}