diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts new file mode 100644 index 000000000..22fae4f93 --- /dev/null +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { assertPublicHttpsUrl } from "./urlDownloader.js"; + +describe("assertPublicHttpsUrl — SSRF guard", () => { + it("accepts public HTTPS URLs", () => { + expect(() => + assertPublicHttpsUrl("https://gen-os-static.s3.us-east-2.amazonaws.com/fonts/font.ttf"), + ).not.toThrow(); + expect(() => assertPublicHttpsUrl("https://cdn.jsdelivr.net/npm/gsap.min.js")).not.toThrow(); + expect(() => assertPublicHttpsUrl("https://fonts.gstatic.com/s/font.woff2")).not.toThrow(); + }); + + it("rejects http:// (non-HTTPS)", () => { + expect(() => assertPublicHttpsUrl("http://example.com/font.ttf")).toThrow("Only HTTPS"); + }); + + it("rejects AWS IMDS (169.254.169.254)", () => { + expect(() => + assertPublicHttpsUrl("https://169.254.169.254/latest/meta-data/iam/security-credentials/"), + ).toThrow("private/reserved"); + expect(() => assertPublicHttpsUrl("http://169.254.169.254/latest/user-data")).toThrow(); + }); + + it("rejects loopback (127.x.x.x)", () => { + expect(() => assertPublicHttpsUrl("https://127.0.0.1/font.ttf")).toThrow("private/reserved"); + expect(() => assertPublicHttpsUrl("https://127.1.2.3/secret")).toThrow("private/reserved"); + }); + + it("rejects localhost", () => { + expect(() => assertPublicHttpsUrl("https://localhost/font.ttf")).toThrow("private/reserved"); + expect(() => assertPublicHttpsUrl("http://localhost:3000/secret")).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"); + }); + + it("rejects RFC1918 — 172.16–172.31", () => { + expect(() => assertPublicHttpsUrl("https://172.16.0.1/secret")).toThrow("private/reserved"); + expect(() => assertPublicHttpsUrl("https://172.31.255.255/secret")).toThrow("private/reserved"); + }); + + it("allows 172.0–172.15 and 172.32+ (not RFC1918)", () => { + expect(() => assertPublicHttpsUrl("https://172.15.0.1/font.ttf")).not.toThrow(); + expect(() => assertPublicHttpsUrl("https://172.32.0.1/font.ttf")).not.toThrow(); + }); + + it("rejects RFC1918 — 192.168.x", () => { + expect(() => assertPublicHttpsUrl("https://192.168.1.1/secret")).toThrow("private/reserved"); + }); + + it("rejects unspecified address (0.x)", () => { + expect(() => assertPublicHttpsUrl("https://0.0.0.0/secret")).toThrow("private/reserved"); + }); + + it("rejects loopback IPv6 ([::1])", () => { + expect(() => assertPublicHttpsUrl("https://[::1]/secret")).toThrow("private/reserved"); + }); + + it("rejects invalid URLs", () => { + expect(() => assertPublicHttpsUrl("not-a-url")).toThrow("Invalid URL"); + expect(() => assertPublicHttpsUrl("")).toThrow("Invalid URL"); + }); +}); diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index 85655cd4f..3dbd65321 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -7,6 +7,62 @@ import { finished } from "stream/promises"; const downloadPathCache = new Map(); const inFlightDownloads = new Map>(); +// SSRF guard: these prefixes identify non-public address space that +// compositions (customer-supplied) must never be able to reach via the +// download path. Blocks AWS IMDS (169.254.169.254), loopback, RFC1918, +// and unspecified addresses. All comparisons are on the raw hostname +// string; DNS resolution is NOT performed here, so DNS-rebinding bypasses +// are not closed by this check — that gap is acceptable for the risk level. +const BLOCKED_HOST_PREFIXES = [ + "169.254.", // link-local / AWS IMDS + "127.", // loopback IPv4 + "10.", // RFC1918 + "192.168.", // RFC1918 + "0.", // unspecified + "[::1]", // loopback IPv6 + "[fc", // RFC4193 unique-local IPv6 + "[fd", // RFC4193 unique-local IPv6 +]; +// 172.16.0.0 – 172.31.255.255 (RFC1918) +const BLOCKED_172_RANGE = { min: 16, max: 31 }; + +function isBlockedHost(hostname: string): boolean { + const h = hostname.toLowerCase(); + if (h === "localhost") return true; + if (BLOCKED_HOST_PREFIXES.some((p) => h.startsWith(p))) return true; + // 172.16–172.31 + const m = h.match(/^172\.(\d{1,3})\./); + if (m) { + const octet = parseInt(m[1] ?? "0", 10); + if (octet >= BLOCKED_172_RANGE.min && octet <= BLOCKED_172_RANGE.max) return true; + } + return false; +} + +/** + * Validate that a URL is safe to fetch on behalf of customer-supplied + * compositions. Throws if the URL is non-HTTPS or targets a private/reserved + * address range (SSRF guard). + */ +export function assertPublicHttpsUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`[URLDownloader] Invalid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new Error( + `[URLDownloader] Only HTTPS URLs are permitted in compositions (got ${parsed.protocol}): ${url}`, + ); + } + if (isBlockedHost(parsed.hostname)) { + throw new Error( + `[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`, + ); + } +} + function getFilenameFromUrl(url: string): string { const hash = createHash("md5").update(url).digest("hex").slice(0, 12); const urlObj = new URL(url); @@ -19,6 +75,11 @@ export async function downloadToTemp( destDir: string, timeoutMs: number = 300000, ): Promise { + // Reject non-HTTPS URLs and private/reserved address ranges before + // touching the cache or filesystem — customer-supplied compositions must + // not be able to trigger outbound fetches to internal infrastructure. + assertPublicHttpsUrl(url); + const cachedPath = downloadPathCache.get(url); if (cachedPath && existsSync(cachedPath)) { return cachedPath; diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 0363c7134..158102663 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -11,6 +11,7 @@ import { discoverAudioVolumeAutomationFromTimeline, inlineExternalScripts, localizeRemoteMediaSources, + localizeRemoteFontFaces, recompileWithResolutions, } from "./htmlCompiler.js"; @@ -949,6 +950,108 @@ describe("localizeRemoteMediaSources", () => { }); }); +// ── localizeRemoteFontFaces ────────────────────────────────────────────────── + +describe("localizeRemoteFontFaces", () => { + const FONT_URL = "https://gen-os-static.s3.us-east-2.amazonaws.com/fonts/komika-axis.ttf"; + + it("rewrites @font-face url() inside `; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).not.toContain(FONT_URL); + expect(result).toContain("_remote_media/"); + expect(remoteMediaAssets.size).toBe(1); + } finally { + globalThis.fetch = orig; + } + }); + + it("ignores url() references outside @font-face (e.g. background-image)", async () => { + const orig = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => new Response(new Uint8Array(16), { status: 200 }); + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-bg-")); + const BG_URL = "https://cdn.example.com/bg.png"; + const html = ``; + const { html: result } = await localizeRemoteFontFaces(html, dl); + // Font URL rewritten, background URL untouched + expect(result).not.toContain(FONT_URL); + expect(result).toContain(BG_URL); + } finally { + globalThis.fetch = orig; + } + }); + + it("preserves original URL when download fails", async () => { + const orig = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => new Response(null, { status: 403 }); + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-fail-")); + const FAIL_URL = "https://fail-font.example.com/f.ttf"; + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toContain(FAIL_URL); + expect(remoteMediaAssets.size).toBe(0); + } finally { + globalThis.fetch = orig; + } + }); + + it("deduplicates: same font URL in two @font-face blocks → 1 download", async () => { + const orig = globalThis.fetch; + let fetchCount = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => { + fetchCount++; + return new Response(new Uint8Array(16), { status: 200 }); + }; + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-dedup-")); + const DEDUP_URL = "https://dedup-font.example.com/d.ttf"; + const html = ``; + const { remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(fetchCount).toBe(1); + expect(remoteMediaAssets.size).toBe(1); + } finally { + globalThis.fetch = orig; + } + }); + + it("no-ops when no @font-face blocks are present", async () => { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-noop-")); + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toBe(html); + expect(remoteMediaAssets.size).toBe(0); + }); + + it("ignores local (non-HTTP) @font-face src URLs", async () => { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-local-")); + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toBe(html); + expect(remoteMediaAssets.size).toBe(0); + }); +}); + describe("discoverAudioVolumeAutomationFromTimeline", () => { it("samples video-derived audio volume without firing GSAP callbacks", async () => { class TestAudioElement {} diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 85edddbc0..cd9c713c5 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -888,40 +888,26 @@ const REMOTE_MEDIA_TAG_RE = /<(?:video|audio)\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi; /** - * Download any remote `src` URLs on `