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", `a`, ["blob:"]], + ["protocol-relative href", `b`, ["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 '='", + `r`, + ["evil.example"], + ], + [ + "SMIL rewrites href to javascript:", + ``, + ["javascript:", " rewrites href to javascript:", + ``, + ["javascript:", "", + `click`, + ["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"', "`, + ['fill="red"'], + ], +]; + +describe("sanitizeSvg: hostile input", () => { + for (const [label, input, mustNotContain] of HOSTILE_CASES) { + it(`drops: ${label}`, () => { + const clean = sanitizeSvg(input); + for (const needle of mustNotContain) + expect(clean, `${label}: ${clean}`).not.toContain(needle); + }); + } +}); + +describe("sanitizeSvg: legitimate content survives", () => { + for (const [label, input, mustContain] of LEGITIMATE_CASES) { + it(`keeps: ${label}`, () => { + const clean = sanitizeSvg(input); + for (const needle of mustContain) expect(clean, `${label}: ${clean}`).toContain(needle); + }); + } + + it("a typical clean figma export keeps its structure and attributes", () => { + const dirty = ``; const clean = sanitizeSvg(dirty); - expect(clean).not.toContain("script"); - expect(clean).toContain(" { - const dirty = `