mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(producer): localize remote @font-face src URLs before render (#1155)
* fix(producer): localize remote @font-face src URLs before render Remote font URLs in @font-face blocks fail with a CORS rejection when the renderer fetches them from http://localhost:PORT (S3 does not echo the local origin in Access-Control-Allow-Origin). Chrome falls back to the next font in the stack (e.g. Arial), producing wrong typography. localizeRemoteFontFaces() scans <style> blocks, extracts HTTP url() references inside @font-face rules, downloads them in parallel into _remote_media/, and rewrites the CSS url() references to local paths — the same pattern as localizeRemoteMediaSources() for <video>/<audio>. Background url() references outside @font-face blocks are intentionally left untouched to avoid downloading arbitrary images. The shared download+rewrite logic is extracted into downloadAndRewriteUrls() to eliminate duplication between the two localize functions. Reported via the Beasty Style caption template (Komika Axis .ttf from S3 falling back to Arial on every cloud render). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): add SSRF guard to downloadToTemp (blocks private/IMDS addresses) Customer-supplied compositions can author @font-face src URLs (and <video>/ <audio> src attrs via the existing localize path) that point to private infrastructure. Without a guard, the producer's downloadToTemp would fetch http://169.254.169.254/... (AWS IMDS), RFC1918, loopback, etc., save the response to _remote_media/, and expose it via the local file server. assertPublicHttpsUrl() rejects: - Non-HTTPS (http://) — all composition fetches must use HTTPS - 169.254.x (AWS link-local / IMDS) - 127.x / localhost / 0.x (loopback / unspecified) - 10.x, 172.16–172.31, 192.168.x (RFC1918) - [::1], [fc...], [fd...] (IPv6 loopback + unique-local) The guard fires before the cache check so a blocked URL never gets into the in-flight map. Applies to both the font-face localize path (PR #1155) and the existing video/audio localize path (PR #1146) since both call downloadToTemp. Note: DNS-rebinding bypasses are not closed by this check (hostname comparison only, no DNS resolution). Acceptable risk for current threat model; server-side DNS validation can be layered on later. 12 unit tests covering all blocked ranges + the allowed edge cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): fix TypeScript strict-mode error in urlDownloader SSRF guard m[1] from RegExp.match() is typed string | undefined; parseInt requires string. Use nullish coalescing to satisfy tsc without changing runtime behavior — the regex guarantees m[1] is always defined when the match succeeds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): use vitest import in urlDownloader test bun:test is not available in CI — the engine package runs tests via vitest. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
bda4a32a7e
commit
b1b03782a1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,62 @@ import { finished } from "stream/promises";
|
||||
const downloadPathCache = new Map<string, string>();
|
||||
const inFlightDownloads = new Map<string, Promise<string>>();
|
||||
|
||||
// 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<string> {
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user