Files
hyperframes/packages/engine/src/utils/urlDownloader.ts
T
Miguel ÁngelandClaude Sonnet 4.6 b1b03782a1 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>
2026-06-01 20:12:53 -04:00

144 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createWriteStream, existsSync, mkdirSync } from "fs";
import { createHash } from "crypto";
import { join, extname } from "path";
import { Readable } from "stream";
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);
const ext = extname(urlObj.pathname) || ".mp4";
return `download_${hash}${ext}`;
}
export async function downloadToTemp(
url: string,
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;
}
const inFlight = inFlightDownloads.get(url);
if (inFlight) {
return inFlight;
}
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
const filename = getFilenameFromUrl(url);
const localPath = join(destDir, filename);
if (existsSync(localPath)) {
downloadPathCache.set(url, localPath);
return localPath;
}
const downloadPromise = (async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is empty");
}
const fileStream = createWriteStream(localPath);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const readableStream = Readable.fromWeb(response.body as any);
await finished(readableStream.pipe(fileStream));
downloadPathCache.set(url, localPath);
return localPath;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("aborted")) {
throw new Error(`[URLDownloader] Download timeout after ${timeoutMs / 1000}s: ${url}`);
}
throw new Error(`[URLDownloader] Download failed: ${message}`);
} finally {
inFlightDownloads.delete(url);
}
})();
inFlightDownloads.set(url, downloadPromise);
return downloadPromise;
}
export function isHttpUrl(path: string): boolean {
return path.startsWith("http://") || path.startsWith("https://");
}