mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(engine): validate remote download integrity (#2938)
This commit is contained in:
@@ -85,6 +85,22 @@ describe("discoverMediaFromBrowser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function validTestMediaResponse(): Response {
|
||||
const bytes = new Uint8Array([
|
||||
0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0, 0x69, 0x73, 0x6f, 0x6d,
|
||||
0x6d, 0x70, 0x34, 0x32,
|
||||
]);
|
||||
return new Response(bytes, { status: 200 });
|
||||
}
|
||||
|
||||
function validTestImageResponse(): Response {
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
return new Response(png, { status: 200 });
|
||||
}
|
||||
|
||||
describe("injectSdkPositionEditsRenderScript", () => {
|
||||
it("injects before </body> when SDK position-edit markers are present", () => {
|
||||
const html =
|
||||
@@ -1424,7 +1440,7 @@ describe("localizeRemoteMediaSources", () => {
|
||||
it("rewrites remote <video> src to _remote_media path when download succeeds", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestMediaResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-ok-"));
|
||||
const html = `<video id="v1" src="https://media-ok.example.com/a/clip.mp4" data-start="0" data-end="10" muted></video>`;
|
||||
@@ -1447,13 +1463,42 @@ describe("localizeRemoteMediaSources", () => {
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
});
|
||||
|
||||
it("logs only a safe fingerprint and host for a signed-URL media failure", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = defaultLogger.warn;
|
||||
const warnings: unknown[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () =>
|
||||
new Response("<!doctype html><html><body>expired</body></html>", { status: 200 });
|
||||
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-safe-log-"));
|
||||
const url = "https://cdn.example/private/customer.mp4?X-Amz-Signature=super-secret-signature";
|
||||
const html = `<video id="v1" src="${url}" data-start="0" data-end="10"></video>`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
|
||||
|
||||
expect(result).toContain(url);
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
expect(warnings).toHaveLength(1);
|
||||
const serialized = JSON.stringify(warnings);
|
||||
expect(serialized).toContain("cdn.example");
|
||||
expect(serialized).toContain("urlFingerprint");
|
||||
expect(serialized).not.toContain("customer.mp4");
|
||||
expect(serialized).not.toContain("super-secret-signature");
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = originalFetch;
|
||||
defaultLogger.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates: two tags with the same src URL → one 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(100), { status: 200 });
|
||||
return validTestMediaResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-dedup-"));
|
||||
@@ -1479,7 +1524,7 @@ describe("localizeRemoteMediaSources", () => {
|
||||
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestMediaResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-dl-quotes-"));
|
||||
const html = `<video id="v1" src="https://q.example.com/c/dq.mp4" data-start="0" data-end="10" muted></video>
|
||||
@@ -1520,7 +1565,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
it("rewrites remote <img> src to _remote_media path when download succeeds", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-ok-"));
|
||||
const html = `<img class="hero" src="https://img-ok.example.com/photo.png" />`;
|
||||
@@ -1549,7 +1594,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(new Uint8Array(100), { status: 200 });
|
||||
return validTestImageResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-dedup-"));
|
||||
@@ -1583,7 +1628,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
it("rewrites both double-quoted and single-quoted src attributes", async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-quotes-"));
|
||||
const html = `<img src="https://q-img.example.com/dq.png" />
|
||||
@@ -1607,7 +1652,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => {
|
||||
fetchCount++;
|
||||
return new Response(new Uint8Array(100), { status: 200 });
|
||||
return validTestImageResponse();
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-datasrc-"));
|
||||
@@ -1628,7 +1673,7 @@ describe("localizeRemoteImageSources", () => {
|
||||
// <img> tags with `class` before `src`. Regex must not assume src position.
|
||||
const orig = globalThis.fetch;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
||||
(globalThis as any).fetch = async () => validTestImageResponse();
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-img-attr-order-"));
|
||||
const html = `<img class="kobe-cutout" alt="kobe" src="https://astral.example.com/d828bca.png" />`;
|
||||
@@ -1798,6 +1843,7 @@ h1 { font-size: 2rem; }`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
||||
// The <link> tag should be replaced with an inline <style> containing the @font-face
|
||||
expect(result).not.toContain(`href="${STYLESHEET_URL}"`);
|
||||
expect(result).not.toContain(STYLESHEET_URL);
|
||||
expect(result).not.toContain("<link");
|
||||
expect(result).toContain("@font-face");
|
||||
expect(result).toContain("CustomFont");
|
||||
@@ -1828,6 +1874,57 @@ h1 { font-size: 2rem; }`;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a stylesheet redirect to a private host before the second request", async () => {
|
||||
const STYLESHEET_URL = "https://styles.example.com/fonts.css";
|
||||
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(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://169.254.169.254/latest/meta-data/" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-ff-private-redirect-"));
|
||||
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
||||
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
||||
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(result).toBe(html);
|
||||
expect(remoteMediaAssets.size).toBe(0);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not log a signed stylesheet path or query on failure", async () => {
|
||||
const STYLESHEET_URL =
|
||||
"https://styles.example.com/private/customer.css?X-Amz-Signature=super-secret";
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWarn = defaultLogger.warn;
|
||||
const warnings: unknown[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = async () =>
|
||||
new Response(null, { status: 503, statusText: STYLESHEET_URL });
|
||||
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
|
||||
try {
|
||||
const dl = mkdtempSync(join(tmpdir(), "hf-ff-safe-style-log-"));
|
||||
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
||||
await localizeRemoteFontFaces(html, dl);
|
||||
|
||||
const serialized = JSON.stringify(warnings);
|
||||
expect(serialized).toContain("styles.example.com");
|
||||
expect(serialized).toContain("urlFingerprint");
|
||||
expect(serialized).not.toContain("customer.css");
|
||||
expect(serialized).not.toContain("super-secret");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
defaultLogger.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps <link> tag when external stylesheet has no @font-face rules", async () => {
|
||||
const STYLESHEET_URL = "https://cdn.example.com/reset.css";
|
||||
const orig = globalThis.fetch;
|
||||
|
||||
@@ -53,7 +53,13 @@ import {
|
||||
analyzeKeyframeIntervals,
|
||||
probeMediaProfile,
|
||||
} from "@hyperframes/engine";
|
||||
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import {
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
type UrlDownloadTelemetry,
|
||||
} from "../utils/urlDownloader.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
import {
|
||||
injectDeterministicFontFaces,
|
||||
@@ -66,6 +72,10 @@ import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import { assertAssetMediaTypeProfile } from "./assetMediaType.js";
|
||||
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
|
||||
|
||||
function logRemoteDownloadTelemetry(event: UrlDownloadTelemetry): void {
|
||||
defaultLogger.info("[Compiler] Remote asset download integrity", { ...event });
|
||||
}
|
||||
|
||||
export interface CompiledComposition {
|
||||
html: string;
|
||||
subCompositions: Map<string, string>;
|
||||
@@ -419,7 +429,9 @@ async function resolveMediaDuration(
|
||||
if (isHttpUrl(src)) {
|
||||
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
|
||||
try {
|
||||
filePath = await downloadToTemp(src, downloadDir);
|
||||
filePath = await downloadToTemp(src, downloadDir, undefined, undefined, undefined, {
|
||||
onTelemetry: logRemoteDownloadTelemetry,
|
||||
});
|
||||
} catch {
|
||||
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
|
||||
// The element will get duration 0 and be excluded from the render.
|
||||
@@ -1306,14 +1318,17 @@ async function downloadAndRewriteUrls(
|
||||
await Promise.all(
|
||||
[...urlSet].map(async (url) => {
|
||||
try {
|
||||
const localPath = await downloadToTemp(url, remoteDir);
|
||||
const localPath = await downloadToTemp(url, remoteDir, undefined, undefined, undefined, {
|
||||
onTelemetry: logRemoteDownloadTelemetry,
|
||||
});
|
||||
urlToLocal.set(url, localPath);
|
||||
} catch (err) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] ${warnLabel} ${url} — using original URL as fallback. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
const identity = safeDownloadUrlIdentity(url);
|
||||
defaultLogger.warn(`[Compiler] ${warnLabel} — using original URL as fallback.`, {
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1369,7 +1384,7 @@ export async function localizeRemoteMediaSources(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote media download failed for",
|
||||
"Remote media download failed",
|
||||
"Localized remote media source(s)",
|
||||
);
|
||||
}
|
||||
@@ -1412,7 +1427,7 @@ export async function localizeRemoteImageSources(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote image download failed for",
|
||||
"Remote image download failed",
|
||||
"Localized remote image source(s)",
|
||||
);
|
||||
}
|
||||
@@ -1450,7 +1465,7 @@ export async function localizeRemoteBackgroundImages(
|
||||
urlSet,
|
||||
html,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote background-image download failed for",
|
||||
"Remote background-image download failed",
|
||||
"Localized remote background-image(s)",
|
||||
// Quoted url('..')/url("..") are rewritten by downloadAndRewriteUrls' default
|
||||
// replaceAll; this handles the unquoted url(https://..) form.
|
||||
@@ -1492,42 +1507,18 @@ function isGoogleFontsUrl(href: string): boolean {
|
||||
const MAX_STYLESHEET_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
async function fetchExternalStylesheetCss(href: string): Promise<string | null> {
|
||||
const identity = safeDownloadUrlIdentity(href);
|
||||
try {
|
||||
assertPublicHttpsUrl(href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(href, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
return await fetchPublicHttpsText(href, {
|
||||
maxBytes: MAX_STYLESHEET_BYTES,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet fetch failed for ${href} — HTTP ${response.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_STYLESHEET_BYTES) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet too large (${contentLength} bytes): ${href}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_STYLESHEET_BYTES) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet too large (${text.length} bytes): ${href}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
defaultLogger.warn(
|
||||
`[Compiler] External stylesheet fetch failed for ${href} — ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
defaultLogger.warn("[Compiler] External stylesheet fetch failed — preserving link tag.", {
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1608,11 +1599,14 @@ async function inlineExternalFontStylesheets(html: string): Promise<string> {
|
||||
if (css === null) continue;
|
||||
const fontFaceBlocks = extractFontFaceBlocks(css);
|
||||
if (fontFaceBlocks.length === 0) continue;
|
||||
const inlineStyle = `<style>/* Inlined from ${href} */\n${fontFaceBlocks.join("\n")}\n</style>`;
|
||||
const identity = safeDownloadUrlIdentity(href);
|
||||
const inlineStyle = `<style>/* Inlined external font stylesheet */\n${fontFaceBlocks.join("\n")}\n</style>`;
|
||||
result = result.replace(fullMatch, inlineStyle);
|
||||
defaultLogger.info(
|
||||
`[Compiler] Inlined ${fontFaceBlocks.length} @font-face rule(s) from external stylesheet: ${href}`,
|
||||
);
|
||||
defaultLogger.info("[Compiler] Inlined external @font-face rule(s)", {
|
||||
count: fontFaceBlocks.length,
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
host: identity.host,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1672,7 +1666,7 @@ export async function localizeRemoteFontFaces(
|
||||
urlSet,
|
||||
processed,
|
||||
join(downloadDir, REMOTE_MEDIA_SUBDIR),
|
||||
"Remote font download failed for",
|
||||
"Remote font download failed",
|
||||
"Localized remote font face(s)",
|
||||
(h, url, relPath) => h.replaceAll(`url(${url})`, `url("${relPath}")`),
|
||||
);
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
* Re-exported from @hyperframes/engine.
|
||||
* @see engine/src/utils/urlDownloader.ts for implementation.
|
||||
*/
|
||||
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "@hyperframes/engine";
|
||||
export {
|
||||
downloadToTemp,
|
||||
fetchPublicHttpsText,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
writeUrlDownloadTelemetry,
|
||||
type UrlDownloadTelemetry,
|
||||
} from "@hyperframes/engine";
|
||||
|
||||
Reference in New Issue
Block a user