diff --git a/.oxlintrc.json b/.oxlintrc.json
index 007a9a6431..c9ef018f3f 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -15,7 +15,8 @@
"registry/blocks/**/lib/*.iife.js",
"registry/blocks/{glass-shard-title,code-slice-hero,cuboid-carousel,frost-sequence-camera-orbit,orbit-card}/**/*.{js,mjs}",
"registry/blocks/frost-sequence-camera-orbit/source/**",
- "chrome/"
+ "chrome/",
+ "packages/cli/src/media-use/lib/svg-sanitize.mjs"
],
"overrides": [
{
diff --git a/.prettierignore b/.prettierignore
index 3cce67a427..00c7408cb4 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -12,6 +12,9 @@ packages/**/__goldens__/
# vendored third-party bundles inside skills — never reformat
skills/**/assets/vendor/
skills/**/*.min.js
+# esbuild-minified media-use svg sanitizer, generated by
+# scripts/generate-media-use-svg-sanitize.mjs — never hand-edit or reformat.
+packages/cli/src/media-use/lib/svg-sanitize.mjs
# vendored/bundled third-party JS inside registry blocks — never reformat.
# Without this the format hook silently re-pretty-prints a minified bundle,
# roughly doubling its size. Listed by exact file: these blocks also ship
diff --git a/package.json b/package.json
index e7022853e3..655634268c 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
- "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
+ "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
diff --git a/packages/cli/src/media-use/lib/coverage.test.mjs b/packages/cli/src/media-use/lib/coverage.test.mjs
index 79d58568bb..a28fdb5aa3 100644
--- a/packages/cli/src/media-use/lib/coverage.test.mjs
+++ b/packages/cli/src/media-use/lib/coverage.test.mjs
@@ -29,7 +29,11 @@ test("weakness: audio-only → media-use resolves image + icon", () => {
test("weakness: no third-party brand logos → media-use resolves logo", () => {
assert.ok(listTypes().includes("logo"), "logo type missing");
- assert.ok(getProviders("logo").length >= 4, "logo cascade incomplete");
+ assert.deepEqual(
+ getProviders("logo").map((p) => p.name),
+ ["thesvg", "github.avatar", "favicon.ddg"],
+ "logo cascade incomplete",
+ );
});
test("weakness: no voice/audio gen → media-use exposes voice + the audio engine", () => {
diff --git a/packages/cli/src/media-use/lib/freeze.mjs b/packages/cli/src/media-use/lib/freeze.mjs
index f4d8609701..d5505a5261 100644
--- a/packages/cli/src/media-use/lib/freeze.mjs
+++ b/packages/cli/src/media-use/lib/freeze.mjs
@@ -1,41 +1,51 @@
-import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs";
-import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
+import { fetchMedia, isPublicMediaUrl, readCappedBody } from "./media-fetch.mjs";
+import { sanitizeSvg } from "./svg-sanitize.mjs";
+import { writeFileSync, copyFileSync, mkdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
// ponytail: bound the download so a hostile/runaway URL can't fill the disk.
// 256MB covers any real media asset; raise if 4K video sources ever exceed it.
const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
+// Bounds only the wait for response headers; a large video body may stream for minutes.
+const FREEZE_HEADERS_TIMEOUT_MS = 10_000;
+
+const isSvgPath = (destPath) => /\.svg$/i.test(destPath);
+
+// Every logo/icon SVG comes from a third-party host (theSVG, a --from URL, a local file), so it
+// passes the Figma import's sanitizeSvg allowlist before it touches disk.
+function writeFrozen(destPath, buffer) {
+ mkdirSync(dirname(destPath), { recursive: true });
+ const bytes = isSvgPath(destPath) ? Buffer.from(sanitizeSvg(buffer.toString("utf8"))) : buffer;
+ writeFileSync(destPath, bytes);
+ return bytes.byteLength;
+}
export async function freezeUrl(url, destPath) {
const where = String(url).slice(0, 80);
- const res = await fetchMedia(url);
+ const headerWait = new AbortController();
+ const timer = setTimeout(
+ () =>
+ headerWait.abort(
+ new Error(`freeze failed: no response within ${FREEZE_HEADERS_TIMEOUT_MS} ms for ${where}`),
+ ),
+ FREEZE_HEADERS_TIMEOUT_MS,
+ );
+ const res = await fetchMedia(url, { signal: headerWait.signal }).finally(() =>
+ clearTimeout(timer),
+ );
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
- // Fail fast on an advertised oversize body before reading a single byte.
- const declared = Number(res.headers.get("content-length"));
- if (declared > MAX_FREEZE_BYTES)
- throw new Error(
- `freeze failed: ${declared} bytes exceeds ${MAX_FREEZE_BYTES} cap for ${where}`,
- );
+ const body = await readCappedBody(res, MAX_FREEZE_BYTES, `freeze failed for ${where}`);
+ if (body.byteLength === 0) throw new Error(`freeze failed: empty response for ${where}`);
- // Stream and abort once the cap is crossed, so a lying/chunked hostile URL
- // can't buffer the whole payload into memory before the check (M1).
- const chunks = [];
- let total = 0;
- for await (const chunk of res.body) {
- total += chunk.length;
- if (total > MAX_FREEZE_BYTES)
- throw new Error(`freeze failed: stream exceeds ${MAX_FREEZE_BYTES} cap for ${where}`);
- chunks.push(chunk);
- }
- if (total === 0) throw new Error(`freeze failed: empty response for ${where}`);
-
- mkdirSync(dirname(destPath), { recursive: true });
- writeFileSync(destPath, Buffer.concat(chunks, total));
- return total;
+ return writeFrozen(destPath, body);
}
export function freezeLocalFile(srcPath, destPath) {
+ if (isSvgPath(destPath)) {
+ writeFrozen(destPath, readFileSync(srcPath));
+ return;
+ }
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
diff --git a/packages/cli/src/media-use/lib/freeze.test.mjs b/packages/cli/src/media-use/lib/freeze.test.mjs
index c71eb76a08..ca633f8e8c 100644
--- a/packages/cli/src/media-use/lib/freeze.test.mjs
+++ b/packages/cli/src/media-use/lib/freeze.test.mjs
@@ -1,6 +1,14 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
-import { isDirectMediaUrl } from "./freeze.mjs";
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { isDirectMediaUrl, freezeUrl, freezeLocalFile } from "./freeze.mjs";
+
+const HOSTILE_SVG =
+ '';
test("accepts direct public media URLs", () => {
assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
@@ -44,3 +52,87 @@ test("rejects local / private hosts (SSRF guard, m11)", () => {
assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
});
+
+test("freezeUrl strips `;
+// Every payload below is drawn from the two adversarial-review reports against this file
+// (hyperframes-4291-b7ace99b.md, hyperframes-4291-c9a5f8f3.md) plus the original hardening
+// suite. Each must fail against the pre-rewrite regex sanitizer and pass against this one.
+const HOSTILE_CASES: Array<[label: string, input: string, mustNotContain: string[]]> = [
+ ["script element", ``, ["">x`,
+ ["data:text"],
+ ],
+ ["blob: href", ``, ["blob:"]],
+ ["protocol-relative href", ``, ["evil"]],
+ [
+ "unquoted javascript: href",
+ ``,
+ ["javascript:"],
+ ],
+ [
+ "unquoted external use href",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "style url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "style @import exfil",
+ ``,
+ ["@import", "evil"],
+ ],
+ [
+ "style expression() exfil",
+ ``,
+ ["expression("],
+ ],
+ [
+ "style javascript: exfil",
+ ``,
+ ["javascript:"],
+ ],
+ [
+ "href via a renamed xlink namespace prefix",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "fill url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "filter url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "mask url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "clip-path url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "marker-start url() exfil",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "unquoted attribute value containing '='",
+ ``,
+ ["evil.example"],
+ ],
+ [
+ "SMIL rewrites href to javascript:",
+ ``,
+ ["javascript:", " rewrites href to javascript:",
+ ``,
+ ["javascript:", "",
+ ``,
+ ["data:image"],
+ ],
+ [
+ "unquoted self-closing slash swallow stays contained (no evil URL survives)",
+ ``,
+ ["evil.example"],
+ ],
+];
+
+const LEGITIMATE_CASES: Array<[label: string, input: string, mustContain: string[]]> = [
+ ["local fragment href", ``, ['href="#localClip"']],
+ [
+ "data:image href kept off (image fetch context, not a navigation)",
+ ``,
+ ["data:image/png;base64,AAAA"],
+ ],
+ ["xlink:href local fragment", ``, ['href="#p"']],
+ [
+ "url(#id) on presentation attributes (clip-path, fill, marker-start)",
+ ``,
+ ['clip-path="url(#clip0)"', 'fill="url(#grad)"', 'marker-start="url(#arrow)"'],
+ ],
+ ["benign style attribute", ``, ['style="fill:#123456"']],
+ [
+ "benign `,
+ [".st0{fill:#5757F5;}", 'class="st0"'],
+ ],
+ [
+ "compute-only CSS functions survive (real theSVG shapes: display-p3 color, transforms, @media)",
+ ``,
+ ["@media (prefers-color-scheme:dark)", "color(display-p3 0.92 0.94 0.94)", "skewX(4)"],
+ ],
+ [
+ "case-sensitive SVG attributes/elements survive (viewBox, linearGradient)",
+ ``,
+ ['viewBox="0 0 10 10"', 'preserveAspectRatio="xMidYMid"', "