diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts index ffa9243a13..158551a445 100644 --- a/packages/cli/src/capture/assetDownloader.test.ts +++ b/packages/cli/src/capture/assetDownloader.test.ts @@ -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); diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index a9730653a9..111316cad6 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -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 = [ - [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 } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6137591552..9c81e3384d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -301,6 +301,7 @@ export { export { assertPublicHttpsUrl, + isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index e08d5ff405..8aecb893a7 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -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"); diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index f3d3a64efd..874db947e0 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -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"); } }