mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(media-use): resolve brand logos from theSVG (#4291)
* feat(media-use): resolve brand logos from theSVG Replace the svgl and simple-icons tiers of the `logo` cascade with a single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand SVGs with color, mono, light/dark and wordmark variants. The tier fetches one manifest pinned to a glincker/thesvg commit on jsDelivr, memoizes it per process, and matches entities locally by normalized slug, title, or alias. That drops the hand-kept svgl alias and simple-icons slug maps. GitHub org avatar and favicon stay as the fallbacks for brands theSVG lacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(media-use): sanitize fetched SVGs and gate theSVG by license freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized; the theSVG tier is now the sole SVG-returning logo provider, so an inline script tag or on-event handler would land verbatim in a user project. Route .svg destinations through the existing Figma sanitizer (new @hyperframes/core/sanitize-svg subpath, synced via package-subpaths). thesvgMatch never checked theSVG's own license field: entries theSVG itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an explicit no-redistribution-license string) could still resolve and freeze into a project. Gate matches to the ten SPDX ids theSVG's submission form accepts; a rejected entry falls through to a lower ranked theSVG match, then to the github/favicon tiers. Also caps the theSVG manifest fetch at 20MB, streamed, matching freeze.mjs's existing cap discipline. It had a timeout but no bound. * chore(skills): regenerate skills-manifest for media-use/motion-graphics The original submission edited skills/media-use and skills/motion-graphics docs without regenerating skills-manifest.json; its own CI never ran (fork approval gate), so the drift went unnoticed until this pass's first CI run caught it. * fix(media-use): drop the sanitizer's workspace import, add parity test freeze.mjs importing @hyperframes/core/sanitize-svg broke the test-skills CI lane: packages/cli/src/media-use tests run with node builtins only, zero install, by design (media-use is vendored standalone into other projects, e.g. packages/desktop). Port the sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the now-unused core export. Parity between the port and packages/core/src/figma/sanitizeSvg.ts is enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs, run in the test:scripts lane, which does have an install) over a hostile-SVG corpus, so the two cannot drift silently. * fix(media-use): close three sanitizer bypasses, add fetch timeout Independent review of the maintainer pass found the SVG sanitizer (packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own port) was itself bypassable: an unquoted href/xlink:href survived, inline style="..." attributes were never checked, and a href bound through a renamed XML namespace prefix (xmlns:x=".../1999/xlink", x:href=...) slipped past the literal "xlink:href" match. Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of literal-spelling regexes: parse each tag's attributes in any quoting form, resolve the local name after stripping any namespace prefix, then drop by local name (any on* handler, any *href whose value is not a #fragment or data:image/ embed, any style attribute containing url(, @import, expression( or javascript:). Ported the fix into media-use's svg-sanitize.mjs identically. Legitimate url(#id) references on non-style attributes (clip-path, fill) are untouched. Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching every other fetchMedia call in this package - it had a size cap but no deadline. New cases for all three bypasses in both sanitizeSvg.test.ts and the parity corpus, each reproducibly failing against the prior head. * fix(media-use): replace regex SVG sanitizer with a DOM allowlist Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses in the same shape each time (unquoted href, style= url(), aliased xlink prefix; then presentation-attribute url(), unquoted '=' handling, SMIL animate/set href rewrite, self-closing-slash tag corruption, data:image/ navigation via <a>). Denylisting attribute names against a hand-rolled tokenizer was the wrong approach regardless of how careful the regex. Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real DOM walk (linkedom, already a dependency of core and cli) with an allowlist: known-safe elements and attributes only, everything else dropped by construction rather than pattern-matched as dangerous. href/xlink:href (any namespace prefix) kept only for a same-document fragment, or data:image/ off non-<a> elements. Any attribute value or <style> text containing url() kept only when every url() argument is a fragment; @import/expression(/javascript: dropped from style content. Serializing from a real DOM means output is always well-formed, so the self-closing-slash corruption class of bug is gone by construction. media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs) can't import linkedom directly: its CI test lane runs bare `node --test` with no install step, so even an external dependency fails to resolve, not just a workspace import. It's now a generated file instead of a hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core source (esbuild, minified, zero remaining imports) and scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build on drift. Verified the generated file runs with plain node in an empty directory with no node_modules on the path. Every payload from both independent-review reports is now a table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the generated media-use copy agree and each independently rejects every non-local url(), javascript:, data: href on <a>, and on* handler. Mutation-proved against the real pre-fix blob, not a synthetic case. Sampled 56 real theSVG icons across all 7 collections to check the allowlist doesn't over-strip legitimate content: found and fixed three missing attributes (fx/fy, enable-background, a blanket data-* allowance) and one real behavioral bug - <style> elements were being dropped outright, silently unstyling any Illustrator-exported logo that colors paths via CSS classes. Fixed by keeping <style> on the allowlist and content-checking its text the same way as an inline style= attribute, with a regression test using that exact shape. Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests 39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean. * fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs, generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's correctness rules 504 times and fails `bun run lint`. Same shape as the existing registry/blocks/**/*.iife.js exclusions: a generated, minified, non-hand-edited bundle, not hand-written source. * fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt Same generated esbuild bundle that needed an oxlint exclusion in the previous commit also fails (minified, no formatting to check against), cascading Preflight into Player perf, regression, Windows render verification, and preview-regression on CI. Same established pattern as the existing registry/blocks/**/*.iife.js and skills/**/*.min.js entries in .prettierignore. * refactor(media-use): reduce complexity flagged by fallow audit isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's per-function thresholds after the rebase pulled in a stricter baseline. Extracted isXmlnsAttribute/isKnownAttribute as named predicates and survivingUnsafeMarkers as a named per-output helper; no behavior change, same guard order, same failure strings. Regenerated svg-sanitize.mjs. * refactor(media-use): make the parity test's marker check table-driven Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers still scored 9 (CRAP 90). The four regex markers are now a table, and the data:image-on-<a> and non-local url() checks are small named helpers. Same checks, same order, same failure strings. * fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg The url()/@import checks scanned raw text for the literal "url(" and "@import". A browser decodes CSS escapes first, so fill="\75 rl(...)", url\28...\29 and @\69mport all loaded an external resource, and image-set("https://...") loads one with no url() at all. Reproduced in headless Chrome: 8 of 8 payloads fired a request through the old code. CSS can only load a resource through a function or @import, so CSS text now passes only if it has no @import, every function is on a compute-only allowlist (color, transform, filter and math functions), and every url() is a same-document fragment, all checked on escape-decoded, lowercased text. Same fix reaches media-use through the regenerated bundle. Checked against all 13,384 files of the pinned theSVG corpus: 13,383 sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)". The parity oracle now fails on any surviving attacker host, so it no longer shares the sanitizer's parsing blind spot. * refactor(media-use): share one capped body reader for freeze and manifest fetchJson copied freezeUrl's declared-length check and streaming size cap line for line. Both now call readCappedBody in media-fetch.mjs, which both already import; it has its own tests, since freeze's cap had none. The freeze cap errors now read "freeze failed for <url>: ..." (no caller or test matches the old wording). Also fixes freeze.mjs's comment, which still called the sanitizer a lexical pass. * style(media-use): format the parity test corpus * fix(media-use): make the sanitizer's CSS scans linear on hostile input The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function check /([-\w]*)\(/: on attacker padding these backtracked cubically and quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000 character word took 7.7s), and freeze runs the sanitizer synchronously on bodies up to 256MB. Both are now single-pass indexOf scans (a million characters in under 15ms), with the same verdict on ordinary input. A url() value holding "(" is rejected, so a nested url( can't hide inside one. A size test holds both payloads under 2s. * test(media-use): size payloads that fail the old scans fast instead of hanging * fix(media-use): stop the freeze timeout from aborting long downloads The 10 s AbortSignal.timeout on freezeUrl also aborted the response body, so any stock video, generated video or LUT that took longer than 10 s to download failed. The timer now covers only the wait for response headers and is cleared once they arrive; the 256 MB cap still bounds the body. * refactor(media-use): trim logo-provider comments to the repo limit The tier list and the thesvgMatch doc block ran past the four-line comment cap that the merge check enforces. Same facts, shorter blocks. * refactor(media-use): name the header wait and restore the variants note Renames the freeze timeout controller to headerWait so it no longer reads as HTTP headers, puts the theSVG variants fact dropped by the comment trim next to the line that picks the default mark, and keeps thesvgMatch on the file's JSDoc style. --------- Co-authored-by: Imad Phelps <imad.phelps@heygen.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Imad Phelps
Claude Opus 5.5
Miguel Angel Simon Sierra
parent
9cee2bd345
commit
d49e7b95e0
+2
-1
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script>' +
|
||||
'<rect onload="alert(2)" width="1" height="1"/>' +
|
||||
'<a href="javascript:alert(3)"><circle r="1"/></a></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 <script>, on* handlers and javascript: hrefs from a fetched SVG", async (t) => {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
"fetch",
|
||||
async () =>
|
||||
new Response(HOSTILE_SVG, { status: 200, headers: { "content-type": "image/svg+xml" } }),
|
||||
);
|
||||
const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "logo.svg");
|
||||
await freezeUrl("https://example.com/hostile.svg", dest);
|
||||
const out = readFileSync(dest, "utf8");
|
||||
assert.ok(!out.includes("<script"), "script element stripped");
|
||||
assert.ok(!out.includes("onload="), "event handler stripped");
|
||||
assert.ok(!out.includes("javascript:"), "javascript: href stripped");
|
||||
assert.ok(out.includes("<circle"), "benign markup survives");
|
||||
});
|
||||
|
||||
// Puts setTimeout and AbortSignal.timeout on node:test's fake clock.
|
||||
function fakeClock(t) {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
t.mock.method(AbortSignal, "timeout", (ms) => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(new DOMException("timed out", "TimeoutError")), ms);
|
||||
return controller.signal;
|
||||
});
|
||||
}
|
||||
|
||||
test("freezeUrl keeps streaming a slow body past the header timeout", async (t) => {
|
||||
fakeClock(t);
|
||||
let body;
|
||||
t.mock.method(globalThis, "fetch", async (_url, { signal }) => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
body = controller;
|
||||
// undici aborts the body stream too when the request signal fires.
|
||||
signal.addEventListener("abort", () => controller.error(signal.reason));
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
});
|
||||
const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "clip.mp4");
|
||||
const frozen = freezeUrl("https://cdn.example.com/clip.mp4", dest);
|
||||
await new Promise(setImmediate);
|
||||
t.mock.timers.tick(60_000);
|
||||
body.enqueue(new Uint8Array([4, 5]));
|
||||
body.close();
|
||||
assert.equal(await frozen, 5);
|
||||
assert.deepEqual([...readFileSync(dest)], [1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test("freezeUrl times out when no response headers arrive", { timeout: 5_000 }, async (t) => {
|
||||
fakeClock(t);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
"fetch",
|
||||
(_url, { signal }) =>
|
||||
new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason))),
|
||||
);
|
||||
const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "clip.mp4");
|
||||
const frozen = freezeUrl("https://cdn.example.com/clip.mp4", dest);
|
||||
t.mock.timers.tick(10_000);
|
||||
await assert.rejects(frozen, /no response within 10000 ms/);
|
||||
});
|
||||
|
||||
test("freezeLocalFile strips the same hostile SVG content from a local source", () => {
|
||||
const srcDir = mkdtempSync(join(tmpdir(), "media-use-freeze-src-"));
|
||||
const src = join(srcDir, "in.svg");
|
||||
writeFileSync(src, HOSTILE_SVG);
|
||||
const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-dest-")), "logo.svg");
|
||||
freezeLocalFile(src, dest);
|
||||
const out = readFileSync(dest, "utf8");
|
||||
assert.ok(!out.includes("<script"));
|
||||
assert.ok(!out.includes("onload="));
|
||||
});
|
||||
|
||||
test("freezeLocalFile still byte-copies a non-SVG file untouched", () => {
|
||||
const srcDir = mkdtempSync(join(tmpdir(), "media-use-freeze-src-"));
|
||||
const src = join(srcDir, "in.png");
|
||||
writeFileSync(src, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-dest-")), "logo.png");
|
||||
freezeLocalFile(src, dest);
|
||||
assert.deepEqual(readFileSync(dest), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { fetchMedia } from "./media-fetch.mjs";
|
||||
import { fetchMedia, readCappedBody } from "./media-fetch.mjs";
|
||||
// Official brand marks — the `logo` type's provider tiers, tried in registry
|
||||
// order. Every tier was verified against a 54-brand stress test (2026-07,
|
||||
// 100% cascade hit). Hit counts below are a snapshot of that run — they
|
||||
// drift as the alias/org maps grow; re-run the stress test to refresh them.
|
||||
// order.
|
||||
//
|
||||
// 1. svgl — official full-color vector SVGs (+ wordmark variants);
|
||||
// 40/54 first-hits. Search is substring-based, so
|
||||
// entities go through alias normalization first
|
||||
// ("nextjs" never matches "Next.js" raw).
|
||||
// 2. simple-icons — monochrome official glyphs; caught the long tail the
|
||||
// others miss (nike, visa, toyota, wechat, bytedance).
|
||||
// Pinned CDN build for determinism.
|
||||
// 3. github avatar — the org's official logo for brands with a GitHub
|
||||
// presence. Known orgs only: guessing a login risks a
|
||||
// same-named personal account.
|
||||
// 4. domain favicon — small-raster last resort (DuckDuckGo ip3). Responses
|
||||
// under ~500B are DDG's globe placeholder, not a hit.
|
||||
// 1. thesvg: one pinned manifest matched locally ("Next.js", "nextjs", "next js" hit one
|
||||
// entry); default full-color mark; skips licenses outside THESVG_ACCEPTED_LICENSES.
|
||||
// 2. github avatar: known orgs only; guessing a login risks a same-named personal account.
|
||||
// 3. domain favicon: DuckDuckGo ip3 last resort; under ~500B is its globe placeholder.
|
||||
//
|
||||
// HeyGen asset search is deliberately absent: for brand queries it returns
|
||||
// generic look-alike icons (0/3 in testing) — worse than a miss. A total miss
|
||||
@@ -26,24 +16,36 @@ import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SVGL_API = "https://api.svgl.app";
|
||||
const SIMPLE_ICONS_CDN = "https://cdn.jsdelivr.net/npm/simple-icons@16.25.0/icons";
|
||||
// Bump by pointing at a newer glincker/thesvg commit; the manifest and every
|
||||
// icon URL resolve against the same pin, so a resolve is reproducible.
|
||||
const THESVG_REV = "e5957fa742c1ebf6da07ac40665bdd01d8add35f";
|
||||
const THESVG_CDN = `https://cdn.jsdelivr.net/gh/glincker/thesvg@${THESVG_REV}`;
|
||||
const THESVG_MANIFEST = `${THESVG_CDN}/src/data/icons.json`;
|
||||
const FAVICON_MIN_BYTES = 500;
|
||||
// ponytail: the manifest is ~3.3MB today; 20MB leaves headroom without trusting an
|
||||
// unbounded body from a CDN edge.
|
||||
const MAX_MANIFEST_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
// svgl search queries per entity, tried in order after the raw entity.
|
||||
const SVGL_ALIASES = {
|
||||
nextjs: ["next.js", "next"],
|
||||
aws: ["amazon web services"],
|
||||
huggingface: ["hugging face"],
|
||||
cocacola: ["coca-cola"],
|
||||
mcdonalds: ["mcdonald's"],
|
||||
};
|
||||
// When several entries share a normalized name (e.g. "slack" and the
|
||||
// "slack-badge" auth button), the brand mark wins over cloud/infra and badge
|
||||
// collections.
|
||||
const THESVG_COLLECTION_RANK = ["brands", "community", "aws", "azure", "gcp", "k8s", "auth-badges"];
|
||||
|
||||
// simple-icons slugs that differ from the normalized entity.
|
||||
const SIMPLE_ICON_SLUGS = {
|
||||
nextjs: "nextdotjs",
|
||||
aws: "amazonwebservices",
|
||||
};
|
||||
// The ten SPDX ids theSVG's submission form accepts and verifies before merge
|
||||
// (thesvg.org LICENSING.md §5); anything else routes through their unaudited
|
||||
// "Other/custom" path.
|
||||
const THESVG_ACCEPTED_LICENSES = new Set([
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"CC-BY-4.0",
|
||||
"CC-BY-SA-4.0",
|
||||
"CC-BY-ND-4.0",
|
||||
"MPL-2.0",
|
||||
]);
|
||||
|
||||
// Known GitHub orgs. Only mapped entities resolve at this tier — a brand name
|
||||
// is NOT a GitHub login, and guessing hits same-named personal accounts.
|
||||
@@ -86,15 +88,32 @@ export function titleMatches(title, entity) {
|
||||
return norm(title) === norm(entity);
|
||||
}
|
||||
|
||||
export function svglQueriesFor(entity) {
|
||||
return [entity, ...(SVGL_ALIASES[norm(entity)] || [])];
|
||||
}
|
||||
|
||||
export function simpleIconSlugsFor(entity) {
|
||||
const slugs = [norm(entity)];
|
||||
const alias = SIMPLE_ICON_SLUGS[norm(entity)];
|
||||
if (alias) slugs.push(alias);
|
||||
return slugs;
|
||||
/** Best theSVG entry for an entity, or null: slug > title > alias, ties to the brands
|
||||
* collection; entries outside THESVG_ACCEPTED_LICENSES are never returned. */
|
||||
export function thesvgMatch(icons, entity) {
|
||||
const want = norm(entity);
|
||||
if (!want || !Array.isArray(icons)) return null;
|
||||
const collectionRank = (icon) => {
|
||||
const i = THESVG_COLLECTION_RANK.indexOf(icon.collection);
|
||||
return i === -1 ? THESVG_COLLECTION_RANK.length : i;
|
||||
};
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const icon of icons) {
|
||||
if (!icon || typeof icon.slug !== "string" || !icon.variants?.default) continue;
|
||||
if (!THESVG_ACCEPTED_LICENSES.has(icon.license)) continue;
|
||||
let field;
|
||||
if (norm(icon.slug) === want) field = 0;
|
||||
else if (titleMatches(icon.title, want)) field = 1;
|
||||
else if ((icon.aliases || []).some((a) => titleMatches(a, want))) field = 2;
|
||||
else continue;
|
||||
const score = field * 100 + collectionRank(icon);
|
||||
if (score < bestScore) {
|
||||
best = icon;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function githubOrgFor(entity) {
|
||||
@@ -108,7 +127,8 @@ export function faviconDomainFor(entity) {
|
||||
async function fetchJson(url) {
|
||||
const res = await fetchMedia(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
const body = await readCappedBody(res, MAX_MANIFEST_BYTES, "fetchJson");
|
||||
return JSON.parse(body.toString("utf8"));
|
||||
}
|
||||
|
||||
async function urlExists(url) {
|
||||
@@ -116,57 +136,58 @@ async function urlExists(url) {
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function svglSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
for (const q of svglQueriesFor(entity)) {
|
||||
let items;
|
||||
try {
|
||||
items = await fetchJson(`${SVGL_API}?search=${encodeURIComponent(q)}`);
|
||||
} catch {
|
||||
return null; // network down — let the next tier try its own host
|
||||
}
|
||||
if (!Array.isArray(items)) continue;
|
||||
const hit = items.find((it) => titleMatches(it.title, q) || titleMatches(it.title, entity));
|
||||
if (!hit) continue;
|
||||
const route = typeof hit.route === "string" ? hit.route : hit.route?.light;
|
||||
if (!route) continue;
|
||||
return {
|
||||
url: route,
|
||||
ext: ".svg",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${hit.title} logo (official mark)`,
|
||||
provider: "svgl",
|
||||
provenance: { entity, query: q, route, wordmark: Boolean(hit.wordmark) },
|
||||
},
|
||||
};
|
||||
// One manifest per process: a multi-logo resolve pays the download once. A
|
||||
// failed load is not cached, so the next call retries.
|
||||
let thesvgManifest = null;
|
||||
|
||||
async function loadThesvgManifest() {
|
||||
if (!thesvgManifest) {
|
||||
thesvgManifest = fetchJson(THESVG_MANIFEST).then((icons) => {
|
||||
if (!Array.isArray(icons)) throw new Error("theSVG manifest: unexpected shape");
|
||||
return icons;
|
||||
});
|
||||
thesvgManifest.catch(() => {
|
||||
thesvgManifest = null;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
return thesvgManifest;
|
||||
}
|
||||
|
||||
export async function simpleIconsSearch(intent, ctx = {}) {
|
||||
/** Test hook: drop the memoized manifest. */
|
||||
export function resetThesvgManifest() {
|
||||
thesvgManifest = null;
|
||||
}
|
||||
|
||||
export async function thesvgSearch(intent, ctx = {}) {
|
||||
const entity = entityFrom(intent, ctx.entity);
|
||||
for (const slug of simpleIconSlugsFor(entity)) {
|
||||
const url = `${SIMPLE_ICONS_CDN}/${slug}.svg`;
|
||||
let ok;
|
||||
try {
|
||||
ok = await urlExists(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!ok) continue;
|
||||
return {
|
||||
url,
|
||||
ext: ".svg",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${entity} logo (official monochrome glyph)`,
|
||||
provider: "simple-icons",
|
||||
provenance: { entity, slug, pinned: "simple-icons@16.25.0" },
|
||||
},
|
||||
};
|
||||
let icons;
|
||||
try {
|
||||
icons = await loadThesvgManifest();
|
||||
} catch {
|
||||
return null; // network down or bad payload — let the next tier try its own host
|
||||
}
|
||||
return null;
|
||||
const hit = thesvgMatch(icons, entity);
|
||||
if (!hit) return null;
|
||||
// theSVG also ships mono, light/dark and wordmark variants; default is the full-color mark.
|
||||
const route = hit.variants.default;
|
||||
return {
|
||||
url: `${THESVG_CDN}/public${route.startsWith("/") ? "" : "/"}${route}`,
|
||||
ext: ".svg",
|
||||
source: "search",
|
||||
metadata: {
|
||||
description: `${hit.title} logo (official mark)`,
|
||||
provider: "thesvg",
|
||||
provenance: {
|
||||
entity,
|
||||
slug: hit.slug,
|
||||
variant: "default",
|
||||
variants: Object.keys(hit.variants),
|
||||
collection: hit.collection,
|
||||
license: hit.license,
|
||||
pinned: `glincker/thesvg@${THESVG_REV}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function githubAvatarSearch(intent, ctx = {}) {
|
||||
|
||||
@@ -4,12 +4,11 @@ import { readFileSync } from "node:fs";
|
||||
import {
|
||||
entityFrom,
|
||||
titleMatches,
|
||||
svglQueriesFor,
|
||||
simpleIconSlugsFor,
|
||||
thesvgMatch,
|
||||
githubOrgFor,
|
||||
faviconDomainFor,
|
||||
svglSearch,
|
||||
simpleIconsSearch,
|
||||
thesvgSearch,
|
||||
resetThesvgManifest,
|
||||
githubAvatarSearch,
|
||||
faviconSearch,
|
||||
} from "./logo-provider.mjs";
|
||||
@@ -27,16 +26,126 @@ test("titleMatches ignores case, spacing, punctuation — and rejects lookalikes
|
||||
assert.ok(!titleMatches("Slackware", "slack"));
|
||||
});
|
||||
|
||||
test("svgl queries include the alias forms the raw entity can't match", () => {
|
||||
assert.ok(svglQueriesFor("nextjs").includes("next.js"));
|
||||
assert.ok(svglQueriesFor("aws").includes("amazon web services"));
|
||||
assert.deepEqual(svglQueriesFor("figma"), ["figma"]);
|
||||
// A slice of the real theSVG manifest shape (src/data/icons.json).
|
||||
const ICONS = [
|
||||
{
|
||||
slug: "nextdotjs",
|
||||
title: "Next.js",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/nextdotjs/default.svg", mono: "/icons/nextdotjs/mono.svg" },
|
||||
},
|
||||
{
|
||||
slug: "slack-badge",
|
||||
title: "Slack",
|
||||
aliases: [],
|
||||
collection: "auth-badges",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/slack-badge/default.svg" },
|
||||
},
|
||||
{
|
||||
slug: "slack",
|
||||
title: "Slack",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/slack/default.svg", wordmark: "/icons/slack/wordmark.svg" },
|
||||
},
|
||||
{
|
||||
slug: "aws",
|
||||
title: "AWS",
|
||||
aliases: ["Amazon Web Services"],
|
||||
collection: "brands",
|
||||
license: "MIT",
|
||||
variants: { default: "/icons/aws/color.svg", mono: "/icons/aws/mono.svg" },
|
||||
},
|
||||
{
|
||||
slug: "coca-cola",
|
||||
title: "Coca-Cola",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/coca-cola/default.svg" },
|
||||
},
|
||||
{
|
||||
slug: "slackware",
|
||||
title: "Slackware",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/slackware/default.svg" },
|
||||
},
|
||||
// Mirrors the real manifest's Microsoft entries verbatim (theSVG's own
|
||||
// "needs maintainer review" wording) — never a valid match.
|
||||
{
|
||||
slug: "power-bi",
|
||||
title: "Power BI",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license:
|
||||
"Microsoft proprietary product icon; no express redistribution license supplied; maintainer review required",
|
||||
variants: { default: "/icons/power-bi/default.svg" },
|
||||
},
|
||||
{
|
||||
slug: "acme-unresolved",
|
||||
title: "Acme Unresolved",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "brand-use",
|
||||
variants: { default: "/icons/acme-unresolved/default.svg" },
|
||||
},
|
||||
];
|
||||
|
||||
test("thesvgMatch finds entries by normalized slug, title, or alias", () => {
|
||||
assert.equal(thesvgMatch(ICONS, "nextjs").slug, "nextdotjs", "title Next.js ≡ nextjs");
|
||||
assert.equal(thesvgMatch(ICONS, "coca cola").slug, "coca-cola");
|
||||
assert.equal(thesvgMatch(ICONS, "amazon web services").slug, "aws", "alias hit");
|
||||
assert.equal(thesvgMatch(ICONS, "zzzbrand"), null);
|
||||
});
|
||||
|
||||
test("simple-icons slugs cover the renamed entries", () => {
|
||||
assert.ok(simpleIconSlugsFor("nextjs").includes("nextdotjs"));
|
||||
assert.ok(simpleIconSlugsFor("aws").includes("amazonwebservices"));
|
||||
assert.deepEqual(simpleIconSlugsFor("nike"), ["nike"]);
|
||||
test("thesvgMatch prefers the brand mark over an auth badge with the same title", () => {
|
||||
assert.equal(thesvgMatch(ICONS, "slack").slug, "slack");
|
||||
});
|
||||
|
||||
test("thesvgMatch never returns a lookalike", () => {
|
||||
assert.equal(thesvgMatch(ICONS, "slackwa"), null);
|
||||
assert.equal(thesvgMatch(ICONS, ""), null);
|
||||
});
|
||||
|
||||
test("thesvgMatch skips an entry outside theSVG's ten accepted SPDX licenses", () => {
|
||||
assert.equal(
|
||||
thesvgMatch(ICONS, "power bi"),
|
||||
null,
|
||||
"Microsoft's own 'needs maintainer review' text",
|
||||
);
|
||||
assert.equal(
|
||||
thesvgMatch(ICONS, "acme unresolved"),
|
||||
null,
|
||||
"brand-use is not on the accepted list",
|
||||
);
|
||||
});
|
||||
|
||||
test("thesvgMatch falls through to a lower-ranked accepted entry when the top match is unlicensed", () => {
|
||||
const icons = [
|
||||
{
|
||||
slug: "acme",
|
||||
title: "Acme",
|
||||
aliases: [],
|
||||
collection: "brands",
|
||||
license: "Proprietary",
|
||||
variants: { default: "/icons/acme/default.svg" },
|
||||
},
|
||||
{
|
||||
slug: "acme-community",
|
||||
title: "Acme",
|
||||
aliases: [],
|
||||
collection: "community",
|
||||
license: "CC0-1.0",
|
||||
variants: { default: "/icons/acme-community/default.svg" },
|
||||
},
|
||||
];
|
||||
assert.equal(thesvgMatch(icons, "acme").slug, "acme-community");
|
||||
});
|
||||
|
||||
test("github avatar tier never guesses an org", () => {
|
||||
@@ -59,45 +168,59 @@ const json = (data) => new Response(JSON.stringify(data), { status: 200 });
|
||||
const status = (code) => new Response(null, { status: code });
|
||||
const bin = (n) => new Response(new Uint8Array(n), { status: 200 });
|
||||
|
||||
test("svglSearch returns the descriptor shape on an exact title hit", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () =>
|
||||
json([{ title: "Figma", route: "https://svgl.app/library/figma.svg" }]),
|
||||
test("thesvgSearch returns a pinned CDN url for the default variant", async (t) => {
|
||||
resetThesvgManifest();
|
||||
t.mock.method(globalThis, "fetch", async () => json(ICONS));
|
||||
const res = await thesvgSearch("AWS logo", {});
|
||||
assert.match(
|
||||
res.url,
|
||||
/^https:\/\/cdn\.jsdelivr\.net\/gh\/glincker\/thesvg@[0-9a-f]{40}\/public\/icons\/aws\/color\.svg$/,
|
||||
);
|
||||
const res = await svglSearch("Figma logo", {});
|
||||
assert.equal(res.url, "https://svgl.app/library/figma.svg");
|
||||
assert.equal(res.ext, ".svg");
|
||||
assert.equal(res.metadata.provider, "svgl");
|
||||
assert.equal(res.metadata.provider, "thesvg");
|
||||
assert.equal(res.metadata.provenance.slug, "aws");
|
||||
assert.deepEqual(res.metadata.provenance.variants, ["default", "mono"]);
|
||||
});
|
||||
|
||||
test("svglSearch skips a non-array payload and retries with the alias query", async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
seen.push(decodeURIComponent(String(url)));
|
||||
return seen.length === 1
|
||||
? json({ error: "unexpected shape" })
|
||||
: json([{ title: "Next.js", route: "https://svgl.app/library/nextjs.svg" }]);
|
||||
});
|
||||
const res = await svglSearch("nextjs logo", {});
|
||||
assert.equal(res.metadata.provenance.query, "next.js", "hit came from the alias query");
|
||||
assert.ok(seen.length >= 2, "raw query then alias");
|
||||
test("thesvgSearch fetches the manifest once per process", async (t) => {
|
||||
resetThesvgManifest();
|
||||
const fetchMock = t.mock.method(globalThis, "fetch", async () => json(ICONS));
|
||||
await thesvgSearch("slack logo", {});
|
||||
await thesvgSearch("nextjs logo", {});
|
||||
assert.equal(await thesvgSearch("zzzbrand logo", {}), null);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test("svglSearch returns null when the network is down — the cascade falls through", async (t) => {
|
||||
test("thesvgSearch returns null when the network is down, then retries next call", async (t) => {
|
||||
resetThesvgManifest();
|
||||
let down = true;
|
||||
t.mock.method(globalThis, "fetch", async () => {
|
||||
throw new Error("network down");
|
||||
if (down) throw new Error("network down");
|
||||
return json(ICONS);
|
||||
});
|
||||
assert.equal(await svglSearch("figma logo", {}), null);
|
||||
assert.equal(await thesvgSearch("figma logo", {}), null);
|
||||
down = false;
|
||||
assert.equal((await thesvgSearch("slack logo", {})).metadata.provenance.slug, "slack");
|
||||
});
|
||||
|
||||
test("simpleIconsSearch falls to the next slug on a 404", async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
seen.push(String(url));
|
||||
return String(url).includes("amazonwebservices") ? status(200) : status(404);
|
||||
});
|
||||
const res = await simpleIconsSearch("aws logo", {});
|
||||
assert.ok(res.url.endsWith("amazonwebservices.svg"));
|
||||
assert.equal(seen.length, 2, "plain slug 404s first, alias slug hits");
|
||||
test("thesvgSearch treats a non-array manifest as a miss", async (t) => {
|
||||
resetThesvgManifest();
|
||||
t.mock.method(globalThis, "fetch", async () => json({ error: "unexpected shape" }));
|
||||
assert.equal(await thesvgSearch("slack logo", {}), null);
|
||||
});
|
||||
|
||||
test("thesvgSearch rejects a manifest body over the size cap instead of buffering it whole", async (t) => {
|
||||
resetThesvgManifest();
|
||||
t.mock.method(globalThis, "fetch", async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
// 4 × 6MB > the 20MB cap, no content-length header (matches jsDelivr).
|
||||
body: (async function* () {
|
||||
for (let i = 0; i < 4; i++) yield new Uint8Array(6 * 1024 * 1024);
|
||||
})(),
|
||||
}));
|
||||
assert.equal(await thesvgSearch("slack logo", {}), null);
|
||||
});
|
||||
|
||||
test("faviconSearch rejects DDG's sub-500B placeholder with null", async (t) => {
|
||||
@@ -120,13 +243,24 @@ test("githubAvatarSearch never touches the network for an unmapped entity", asyn
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("the real logo cascade resolves a theSVG brand on tier 1", async (t) => {
|
||||
resetThesvgManifest();
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
if (String(url).includes("glincker/thesvg")) return json(ICONS);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
const res = await runProviders(getProviders("logo"), "search", "Next.js logo", {});
|
||||
assert.equal(res.metadata.provider, "thesvg");
|
||||
assert.ok(res.url.endsWith("/public/icons/nextdotjs/default.svg"));
|
||||
});
|
||||
|
||||
test("the real logo cascade falls through tier by tier to the first hit", async (t) => {
|
||||
resetThesvgManifest();
|
||||
t.mock.method(globalThis, "fetch", async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("api.svgl.app")) return json([]); // tier 1: no hit
|
||||
if (u.includes("jsdelivr")) return status(404); // tier 2: no such slug
|
||||
// tier 3 (github) is never called: entity is unmapped
|
||||
if (u.includes("duckduckgo")) return bin(600); // tier 4: real favicon
|
||||
if (u.includes("glincker/thesvg")) return json(ICONS); // tier 1: no entry
|
||||
// tier 2 (github) is never called: entity is unmapped
|
||||
if (u.includes("duckduckgo")) return bin(600); // tier 3: real favicon
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
});
|
||||
const res = await runProviders(getProviders("logo"), "search", "zzzbrand logo", {
|
||||
@@ -136,19 +270,29 @@ test("the real logo cascade falls through tier by tier to the first hit", async
|
||||
assert.equal(res.metadata.provider, "favicon.ddg");
|
||||
});
|
||||
|
||||
for (const search of [simpleIconsSearch, githubAvatarSearch]) {
|
||||
test(`${search.name} rejects private HEAD redirects`, async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url, options) => {
|
||||
seen.push(url);
|
||||
assert.equal(options.method, "HEAD");
|
||||
assert.ok(options.signal);
|
||||
return options.redirect === "manual"
|
||||
? new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } })
|
||||
: new Response(null, { status: 200 });
|
||||
});
|
||||
assert.equal(await search("vercel logo"), null);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.ok(!seen[0].includes("127.0.0.1"));
|
||||
test("githubAvatarSearch rejects private HEAD redirects", async (t) => {
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url, options) => {
|
||||
seen.push(url);
|
||||
assert.equal(options.method, "HEAD");
|
||||
assert.ok(options.signal);
|
||||
return options.redirect === "manual"
|
||||
? new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } })
|
||||
: new Response(null, { status: 200 });
|
||||
});
|
||||
}
|
||||
assert.equal(await githubAvatarSearch("vercel logo"), null);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.ok(!seen[0].includes("127.0.0.1"));
|
||||
});
|
||||
|
||||
test("thesvgSearch rejects a manifest redirect to a private host", async (t) => {
|
||||
resetThesvgManifest();
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, "fetch", async (url, options) => {
|
||||
seen.push(url);
|
||||
assert.equal(options.redirect, "manual");
|
||||
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } });
|
||||
});
|
||||
assert.equal(await thesvgSearch("vercel logo"), null);
|
||||
assert.equal(seen.length, 1);
|
||||
});
|
||||
|
||||
@@ -69,3 +69,17 @@ export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetc
|
||||
}
|
||||
throw new Error("Media download exceeded redirect limit");
|
||||
}
|
||||
|
||||
// Stream and abort once the cap is crossed, so a lying or chunked body can't buffer past it.
|
||||
export async function readCappedBody(res, maxBytes, label) {
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (declared > maxBytes) throw new Error(`${label}: ${declared} bytes exceeds ${maxBytes} cap`);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const chunk of res.body) {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) throw new Error(`${label}: stream exceeds ${maxBytes} cap`);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs";
|
||||
import { fetchMedia, isPublicMediaUrl, readCappedBody } from "./media-fetch.mjs";
|
||||
import { freezeUrl } from "./freeze.mjs";
|
||||
import { downloadTo } from "../../audio/scripts/lib/heygen.mjs";
|
||||
import { synthesizeHeygen } from "../../audio/scripts/lib/tts.mjs";
|
||||
@@ -167,3 +167,31 @@ for (const entry of ["heygen audio", "tts mp3", "tts wav", "favicon"]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function streamed(sizes, contentLength = null) {
|
||||
let pulled = 0;
|
||||
const body = (async function* () {
|
||||
for (const n of sizes) {
|
||||
pulled++;
|
||||
yield new Uint8Array(n);
|
||||
}
|
||||
})();
|
||||
return { res: { headers: { get: () => contentLength }, body }, pulled: () => pulled };
|
||||
}
|
||||
|
||||
test("readCappedBody refuses a declared oversize body before reading a byte", async () => {
|
||||
const { res, pulled } = streamed([10], "101");
|
||||
await assert.rejects(readCappedBody(res, 100, "t"), /t: 101 bytes exceeds 100 cap/);
|
||||
assert.equal(pulled(), 0);
|
||||
});
|
||||
|
||||
test("readCappedBody aborts a chunked body the moment it crosses the cap", async () => {
|
||||
const { res, pulled } = streamed([60, 60, 60]);
|
||||
await assert.rejects(readCappedBody(res, 100, "t"), /t: stream exceeds 100 cap/);
|
||||
assert.equal(pulled(), 2);
|
||||
});
|
||||
|
||||
test("readCappedBody returns every byte of a body at the cap", async () => {
|
||||
const { res } = streamed([60, 40]);
|
||||
assert.equal((await readCappedBody(res, 100, "t")).byteLength, 100);
|
||||
});
|
||||
|
||||
@@ -25,12 +25,7 @@ import { sfxProvider } from "./sfx-provider.mjs";
|
||||
import { bundledSfxProvider } from "./bundled-sfx-provider.mjs";
|
||||
import { imageProvider, iconProvider } from "./image-provider.mjs";
|
||||
import { brandProvider } from "./brand-provider.mjs";
|
||||
import {
|
||||
svglSearch,
|
||||
simpleIconsSearch,
|
||||
githubAvatarSearch,
|
||||
faviconSearch,
|
||||
} from "./logo-provider.mjs";
|
||||
import { thesvgSearch, githubAvatarSearch, faviconSearch } from "./logo-provider.mjs";
|
||||
import { heygenTtsGenerate } from "./voice-provider.mjs";
|
||||
import { heygenVideoGenerate } from "./heygen-video-provider.mjs";
|
||||
import { ltxVideoGenerate } from "./ltx-video-provider.mjs";
|
||||
@@ -65,12 +60,11 @@ const REGISTRY = {
|
||||
],
|
||||
icon: [N("heygen.asset.search", { search: iconProvider.search })],
|
||||
logo: [
|
||||
// Official brand marks. Tiers verified by a 54-brand stress test (100%
|
||||
// cascade hit); HeyGen asset search is deliberately absent — it returns
|
||||
// generic look-alike icons for brand queries. All free, all network →
|
||||
// --local-only leaves only the cache rungs.
|
||||
N("svgl", { search: svglSearch }),
|
||||
N("simple-icons", { search: simpleIconsSearch }),
|
||||
// Official brand marks: theSVG first, then GitHub org avatar and favicon
|
||||
// for the brands it lacks. HeyGen asset search is deliberately absent — it
|
||||
// returns generic look-alike icons for brand queries. All free, all
|
||||
// network → --local-only leaves only the cache rungs.
|
||||
N("thesvg", { search: thesvgSearch }),
|
||||
N("github.avatar", { search: githubAvatarSearch }),
|
||||
N("favicon.ddg", { search: faviconSearch }),
|
||||
],
|
||||
|
||||
@@ -43,7 +43,7 @@ test("heygen provider is first for every type it serves except bundled sfx", ()
|
||||
|
||||
test("sanctioned providers only: heygen, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => {
|
||||
const allowed =
|
||||
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
|
||||
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^thesvg$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
|
||||
for (const t of listTypes()) {
|
||||
for (const p of getProviders(t)) {
|
||||
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -368,7 +368,7 @@ test("manifest hit for a non-heygen provider omits authMethod entirely", () => {
|
||||
id: "logo_001",
|
||||
type: "logo",
|
||||
path: ".media/images/logo_001.svg",
|
||||
provenance: { provider: "svgl", prompt: "acme logo" },
|
||||
provenance: { provider: "thesvg", prompt: "acme logo" },
|
||||
});
|
||||
appendRecord(tmp, record);
|
||||
const filePath = join(tmp, record.path);
|
||||
|
||||
@@ -2,97 +2,287 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeSvg } from "./sanitizeSvg";
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips <script> blocks including content", () => {
|
||||
const dirty = `<svg><script>alert(1)</script><rect width="1" height="1"/></svg>`;
|
||||
// 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", `<svg><script>alert(1)</script><rect/></svg>`, ["<script", "alert(1)"]],
|
||||
["self-closing script", `<svg><script src="//evil"/></svg>`, ["<script", "evil"]],
|
||||
["style element (@import exfil)", `<svg><style>@import "//evil";</style></svg>`, ["evil"]],
|
||||
[
|
||||
"style element with external url()",
|
||||
`<svg><style>.a{fill:url(https://evil.example/x)}</style></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"style element with javascript:",
|
||||
`<svg><style>.a{background:url('javascript:alert(1)')}</style></svg>`,
|
||||
["javascript:"],
|
||||
],
|
||||
[
|
||||
"style element with expression()",
|
||||
`<svg><style>.a{width:expression(alert(1))}</style></svg>`,
|
||||
["expression("],
|
||||
],
|
||||
[
|
||||
"CSS-escaped url() in style=",
|
||||
String.raw`<svg><rect style="fill:\75 rl(https://evil.example/b)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"CSS-escaped url() in a presentation attribute",
|
||||
String.raw`<svg><rect fill="\75rl(https://evil.example/b)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"CSS-escaped parens around a url in style=",
|
||||
String.raw`<svg><rect style="fill:url\28 https://evil.example/b\29 "/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"CSS-escaped url() in a style element",
|
||||
String.raw`<svg><style>.a{fill:\55 RL(https://evil.example/b)}</style></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"CSS-escaped @import in a style element",
|
||||
String.raw`<svg><style>@\69mport 'https://evil.example/x.css';</style></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"image-set() string URL in a style element (no url() at all)",
|
||||
`<svg><style>svg{background-image:image-set("https://evil.example/b" 1x)}</style></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"CSS-escaped image-set() in style=",
|
||||
String.raw`<svg><rect style="background:\69mage-set('https://evil.example/b' 1x)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"foreignObject subtree",
|
||||
`<svg><foreignObject><body onload="alert(1)"/></foreignObject></svg>`,
|
||||
["foreignObject", "onload"],
|
||||
],
|
||||
["self-closing foreignObject", `<svg><foreignObject/></svg>`, ["foreignObject"]],
|
||||
["quoted on* handler", `<svg><rect onload="alert(1)"/></svg>`, ["onload"]],
|
||||
["single-quoted on* handler", `<svg><rect onload='alert(1)'/></svg>`, ["onload"]],
|
||||
["unquoted on* handler", `<svg><rect onload=alert(1)/></svg>`, ["onload", "alert"]],
|
||||
["mixed-case on* handler", `<svg><rect ONCLICK='y'/></svg>`, ["ONCLICK", "onclick"]],
|
||||
[
|
||||
"quoted javascript: href",
|
||||
`<svg><a href="javascript:alert(1)"><rect/></a></svg>`,
|
||||
["javascript:"],
|
||||
],
|
||||
[
|
||||
"quoted javascript: xlink:href",
|
||||
`<svg><a xlink:href="javascript:alert(1)"/></svg>`,
|
||||
["javascript:"],
|
||||
],
|
||||
[
|
||||
"data:text/html href (not data:image/)",
|
||||
`<svg><a href="data:text/html,<script>1</script>">x</a></svg>`,
|
||||
["data:text"],
|
||||
],
|
||||
["blob: href", `<svg><a href="blob:x">a</a></svg>`, ["blob:"]],
|
||||
["protocol-relative href", `<svg><a href='//evil/x'>b</a></svg>`, ["evil"]],
|
||||
[
|
||||
"unquoted javascript: href",
|
||||
`<svg><a href=javascript:alert(1)><rect/></a></svg>`,
|
||||
["javascript:"],
|
||||
],
|
||||
[
|
||||
"unquoted external use href",
|
||||
`<svg><use href=https://evil.example/x.svg#p/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"style url() exfil",
|
||||
`<svg><rect style="fill:url(https://evil.example/x)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"style @import exfil",
|
||||
`<svg><rect style="background:@import url(evil)"/></svg>`,
|
||||
["@import", "evil"],
|
||||
],
|
||||
[
|
||||
"style expression() exfil",
|
||||
`<svg><rect style="width:expression(alert(1))"/></svg>`,
|
||||
["expression("],
|
||||
],
|
||||
[
|
||||
"style javascript: exfil",
|
||||
`<svg><rect style="background:url('javascript:alert(1)')"/></svg>`,
|
||||
["javascript:"],
|
||||
],
|
||||
[
|
||||
"href via a renamed xlink namespace prefix",
|
||||
`<svg xmlns:x="http://www.w3.org/1999/xlink"><use x:href="https://evil.example/x.svg#p"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"fill url() exfil",
|
||||
`<svg><rect fill="url(https://evil.example/x.svg#p)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"filter url() exfil",
|
||||
`<svg><rect filter="url(https://evil.example/x.svg#f)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"mask url() exfil",
|
||||
`<svg><rect mask="url(https://evil.example/x.svg#m)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"clip-path url() exfil",
|
||||
`<svg><rect clip-path="url(https://evil.example/x.svg#c)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"marker-start url() exfil",
|
||||
`<svg><path marker-start="url(https://evil.example/x.svg#m)"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"unquoted attribute value containing '='",
|
||||
`<svg><rect style=x=y;fill:url(https://evil.example/beacon)>r</rect></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
[
|
||||
"SMIL <animate> rewrites href to javascript:",
|
||||
`<svg><a href="#a"><animate attributeName="href" to="javascript:alert(1)"/></a></svg>`,
|
||||
["javascript:", "<animate", "attributeName"],
|
||||
],
|
||||
[
|
||||
"SMIL <set> rewrites href to javascript:",
|
||||
`<svg><a href="#a"><set attributeName="href" to="javascript:alert(1)"/></a></svg>`,
|
||||
["javascript:", "<set", "attributeName"],
|
||||
],
|
||||
[
|
||||
"data:image/svg+xml href navigates when clicked on <a>",
|
||||
`<svg><a href="data:image/svg+xml;base64,PHN2Zz48c2NyaXB0PmFsZXJ0KDEpPC9zY3JpcHQ+PC9zdmc+">click</a></svg>`,
|
||||
["data:image"],
|
||||
],
|
||||
[
|
||||
"unquoted self-closing slash swallow stays contained (no evil URL survives)",
|
||||
`<svg><use href=https://evil.example/x.svg#p/><rect width="10" height="10" fill="red"/></svg>`,
|
||||
["evil.example"],
|
||||
],
|
||||
];
|
||||
|
||||
const LEGITIMATE_CASES: Array<[label: string, input: string, mustContain: string[]]> = [
|
||||
["local fragment href", `<svg><use href="#localClip"/></svg>`, ['href="#localClip"']],
|
||||
[
|
||||
"data:image href kept off <a> (image fetch context, not a navigation)",
|
||||
`<svg><image href="data:image/png;base64,AAAA"/></svg>`,
|
||||
["data:image/png;base64,AAAA"],
|
||||
],
|
||||
["xlink:href local fragment", `<svg><use xlink:href="#p"/></svg>`, ['href="#p"']],
|
||||
[
|
||||
"url(#id) on presentation attributes (clip-path, fill, marker-start)",
|
||||
`<svg><rect clip-path="url(#clip0)" fill="url(#grad)" marker-start="url(#arrow)"/></svg>`,
|
||||
['clip-path="url(#clip0)"', 'fill="url(#grad)"', 'marker-start="url(#arrow)"'],
|
||||
],
|
||||
["benign style attribute", `<svg><rect style="fill:#123456"/></svg>`, ['style="fill:#123456"']],
|
||||
[
|
||||
"benign <style> block with class selectors survives (Illustrator-exported logos rely on this for fill color)",
|
||||
`<svg><style>.st0{fill:#5757F5;}</style><path class="st0" d="M1 1"/></svg>`,
|
||||
[".st0{fill:#5757F5;}", 'class="st0"'],
|
||||
],
|
||||
[
|
||||
"compute-only CSS functions survive (real theSVG shapes: display-p3 color, transforms, @media)",
|
||||
`<svg><style>@media (prefers-color-scheme:dark){.a{fill:rgb(1,2,3)}}</style><path style="fill:#EBF0F0;fill:color(display-p3 0.92 0.94 0.94)" transform="matrix(1 0 0 1 2 3) skewX(4)" d="M1 1"/></svg>`,
|
||||
["@media (prefers-color-scheme:dark)", "color(display-p3 0.92 0.94 0.94)", "skewX(4)"],
|
||||
],
|
||||
[
|
||||
"case-sensitive SVG attributes/elements survive (viewBox, linearGradient)",
|
||||
`<svg viewBox="0 0 10 10" preserveAspectRatio="xMidYMid"><linearGradient id="g"/></svg>`,
|
||||
['viewBox="0 0 10 10"', 'preserveAspectRatio="xMidYMid"', "<linearGradient"],
|
||||
],
|
||||
[
|
||||
"sibling content after a malformed unquoted-href tag is not lost",
|
||||
`<svg><use href=https://evil.example/x.svg#p/><rect width="10" height="10" fill="red"/></svg>`,
|
||||
['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 = `<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z" fill="#123456" clip-path="url(#clip0)"/><defs><clipPath id="clip0"><rect width="10" height="10"/></clipPath></defs></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("script");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
|
||||
it("strips foreignObject subtrees", () => {
|
||||
const dirty = `<svg><foreignObject><iframe src="https://evil"/></foreignObject><circle r="2"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
expect(clean).toContain("<circle");
|
||||
});
|
||||
|
||||
it("strips on* event handler attributes", () => {
|
||||
const dirty = `<svg onload="evil()"><rect onclick="evil()" width="1"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("onload");
|
||||
expect(clean).not.toContain("onclick");
|
||||
expect(clean).toContain('width="1"');
|
||||
});
|
||||
|
||||
it("strips javascript: and external http(s) hrefs but keeps local fragments", () => {
|
||||
const dirty = [
|
||||
`<svg>`,
|
||||
`<a href="javascript:evil()"><text>x</text></a>`,
|
||||
`<use xlink:href="https://evil.example/sprite.svg#icon"/>`,
|
||||
`<use href="#localClip"/>`,
|
||||
`</svg>`,
|
||||
].join("");
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("javascript:");
|
||||
expect(clean).not.toContain("evil.example");
|
||||
expect(clean).toContain('href="#localClip"');
|
||||
});
|
||||
|
||||
it("keeps data:image hrefs (figma embeds rasters this way)", () => {
|
||||
const dirty = `<svg><image href="data:image/png;base64,AAAA"/></svg>`;
|
||||
expect(sanitizeSvg(dirty)).toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves a typical clean figma export untouched apart from whitespace", () => {
|
||||
const clean = `<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z" fill="#123456" clip-path="url(#clip0)"/><defs><clipPath id="clip0"><rect width="10" height="10"/></clipPath></defs></svg>`;
|
||||
expect(sanitizeSvg(clean)).toBe(clean);
|
||||
for (const needle of [
|
||||
'width="10"',
|
||||
'height="10"',
|
||||
'viewBox="0 0 10 10"',
|
||||
'd="M0 0h10v10H0z"',
|
||||
'fill="#123456"',
|
||||
'clip-path="url(#clip0)"',
|
||||
"<defs>",
|
||||
"<clipPath",
|
||||
"<rect",
|
||||
]) {
|
||||
expect(clean).toContain(needle);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg hardening", () => {
|
||||
it("strips nested script/foreignObject without leaving inner content", () => {
|
||||
const dirty = `<svg><foreignObject><foreignObject><iframe/></foreignObject></foreignObject></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
describe("sanitizeSvg: structural edge cases", () => {
|
||||
it("returns empty output for a document with no root element", () => {
|
||||
expect(sanitizeSvg("")).toBe("");
|
||||
});
|
||||
it("strips <style> blocks (css @import exfil)", () => {
|
||||
const clean = sanitizeSvg(`<svg><style>@import url("https://x/y.css");</style><rect/></svg>`);
|
||||
expect(clean).not.toContain("style");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
it("strips unquoted on* handlers", () => {
|
||||
expect(sanitizeSvg(`<svg><rect onclick=evil() /></svg>`)).not.toContain("onclick");
|
||||
});
|
||||
it("drops non-image data: hrefs but keeps data:image and #fragments", () => {
|
||||
const dirty = `<svg><a href="data:text/html,<script>1</script>">x</a><use href="#clip"/><image href="data:image/png;base64,AA"/></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("data:text/html");
|
||||
expect(clean).toContain('href="#clip"');
|
||||
expect(clean).toContain("data:image/png");
|
||||
});
|
||||
it("drops blob: and protocol-relative hrefs", () => {
|
||||
const clean = sanitizeSvg(`<svg><a href="blob:x">a</a><a href='//evil/x'>b</a></svg>`);
|
||||
expect(clean).not.toContain("blob:");
|
||||
expect(clean).not.toContain("//evil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg close-tag variants (js/bad-tag-filter)", () => {
|
||||
it("strips scripts whose close tag carries whitespace or junk before >", () => {
|
||||
const dirty = "<svg><script>evil()</script\t\n bar><rect/></svg>";
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("script");
|
||||
expect(clean).not.toContain("evil");
|
||||
expect(clean).toContain("<rect");
|
||||
it("drops an entire element not on the allowlist, including its children", () => {
|
||||
const clean = sanitizeSvg(`<svg><customElement><rect fill="red"/></customElement></svg>`);
|
||||
expect(clean).not.toContain("customElement");
|
||||
expect(clean).not.toContain("fill");
|
||||
});
|
||||
it("strips style/foreignObject with attribute-bearing close tags", () => {
|
||||
const dirty = `<svg><style>@import url(x)</style x><foreignObject><iframe/></foreignObject foo="1"></svg>`;
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain("style");
|
||||
|
||||
it("drops nested script/foreignObject without leaving inner content", () => {
|
||||
const clean = sanitizeSvg(
|
||||
`<svg><foreignObject><foreignObject><iframe/></foreignObject></foreignObject></svg>`,
|
||||
);
|
||||
expect(clean).not.toContain("foreignObject");
|
||||
expect(clean).not.toContain("iframe");
|
||||
});
|
||||
});
|
||||
|
||||
// Each took 8s+ under the earlier regex scans (cubic and quadratic backtracking); now ~3ms.
|
||||
describe("sanitizeSvg: hostile input size", () => {
|
||||
const HUGE_CASES: Array<[label: string, input: string]> = [
|
||||
[
|
||||
"unclosed url( + 4k spaces in style=",
|
||||
`<svg><rect style="fill:url(${" ".repeat(4_000)}x"/></svg>`,
|
||||
],
|
||||
[
|
||||
"100k-char word with no paren in <style>",
|
||||
`<svg><style>.a{fill:${"a".repeat(100_000)}}</style></svg>`,
|
||||
],
|
||||
];
|
||||
for (const [label, input] of HUGE_CASES) {
|
||||
it(`stays fast on ${label}`, () => {
|
||||
const started = performance.now();
|
||||
sanitizeSvg(input);
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,58 +1,378 @@
|
||||
/**
|
||||
* Sanitize a figma-exported SVG before it touches disk (design spec §5).
|
||||
*
|
||||
* Threat model: figma-SHAPED exports, hardened against the cheap adversarial
|
||||
* variants (nesting, unquoted attrs, scheme smuggling). This is a lexical
|
||||
* pass, not a general-purpose HTML sanitizer — content from arbitrary
|
||||
* untrusted sources should go through a real sanitizer (DOMPurify) instead.
|
||||
* Strips:
|
||||
* - <script> elements (with content) and <style> blocks (@import exfil)
|
||||
* - <foreignObject> subtrees (arbitrary embedded HTML)
|
||||
* - on* event-handler attributes (quoted and unquoted)
|
||||
* - href/xlink:href values unless local fragment (#id) or data:image/
|
||||
*
|
||||
* Keeps local fragment refs (#id) and data:image embeds, which figma uses
|
||||
* for clip paths and embedded rasters.
|
||||
/** Sanitize a figma-exported SVG before it touches disk (design spec §5). DOM-walk allowlist
|
||||
* (linkedom): unknown elements and attributes are dropped by default rather than pattern-matched
|
||||
* as dangerous, so a new SVG feature can't reopen a class of bug the way regex denylisting did.
|
||||
*/
|
||||
import { DOMParser } from "linkedom";
|
||||
|
||||
/** Apply a replacement until the output stops changing (defeats nesting). */
|
||||
function replaceStable(input: string, pattern: RegExp, replacement: string): string {
|
||||
let out = input;
|
||||
let prev;
|
||||
do {
|
||||
prev = out;
|
||||
out = out.replace(pattern, replacement);
|
||||
} while (out !== prev);
|
||||
return out;
|
||||
const SAFE_ELEMENTS = new Set([
|
||||
"svg",
|
||||
"g",
|
||||
"defs",
|
||||
"symbol",
|
||||
"use",
|
||||
"image",
|
||||
"a",
|
||||
"switch",
|
||||
"rect",
|
||||
"circle",
|
||||
"ellipse",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"path",
|
||||
"text",
|
||||
"tspan",
|
||||
"textPath",
|
||||
"linearGradient",
|
||||
"radialGradient",
|
||||
"stop",
|
||||
"pattern",
|
||||
"mask",
|
||||
"clipPath",
|
||||
"marker",
|
||||
"filter",
|
||||
"feBlend",
|
||||
"feColorMatrix",
|
||||
"feComponentTransfer",
|
||||
"feComposite",
|
||||
"feConvolveMatrix",
|
||||
"feDiffuseLighting",
|
||||
"feDisplacementMap",
|
||||
"feDistantLight",
|
||||
"feDropShadow",
|
||||
"feFlood",
|
||||
"feFuncA",
|
||||
"feFuncB",
|
||||
"feFuncG",
|
||||
"feFuncR",
|
||||
"feGaussianBlur",
|
||||
"feImage",
|
||||
"feMerge",
|
||||
"feMergeNode",
|
||||
"feMorphology",
|
||||
"feOffset",
|
||||
"fePointLight",
|
||||
"feSpecularLighting",
|
||||
"feSpotLight",
|
||||
"feTile",
|
||||
"feTurbulence",
|
||||
"title",
|
||||
"desc",
|
||||
"metadata",
|
||||
"style",
|
||||
]);
|
||||
|
||||
// Local names only — a namespace prefix (xlink:href, x:href, ...) is stripped before this
|
||||
// lookup, so it doesn't matter which prefix an attacker aliases onto a real or fake namespace.
|
||||
const SAFE_ATTRIBUTES = new Set([
|
||||
"id",
|
||||
"class",
|
||||
"style",
|
||||
"transform",
|
||||
"viewBox",
|
||||
"width",
|
||||
"height",
|
||||
"x",
|
||||
"y",
|
||||
"x1",
|
||||
"y1",
|
||||
"x2",
|
||||
"y2",
|
||||
"cx",
|
||||
"cy",
|
||||
"r",
|
||||
"rx",
|
||||
"ry",
|
||||
"fx",
|
||||
"fy",
|
||||
"points",
|
||||
"d",
|
||||
"offset",
|
||||
"enable-background",
|
||||
"gradientUnits",
|
||||
"gradientTransform",
|
||||
"spreadMethod",
|
||||
"patternUnits",
|
||||
"patternContentUnits",
|
||||
"patternTransform",
|
||||
"maskUnits",
|
||||
"maskContentUnits",
|
||||
"clipPathUnits",
|
||||
"clip-rule",
|
||||
"filterUnits",
|
||||
"primitiveUnits",
|
||||
"preserveAspectRatio",
|
||||
"space",
|
||||
"lang",
|
||||
"role",
|
||||
"focusable",
|
||||
"tabindex",
|
||||
"version",
|
||||
"baseProfile",
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"fill-rule",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"stroke-opacity",
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
"stroke-miterlimit",
|
||||
"stroke-dasharray",
|
||||
"stroke-dashoffset",
|
||||
"opacity",
|
||||
"color",
|
||||
"stop-color",
|
||||
"stop-opacity",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"text-anchor",
|
||||
"dominant-baseline",
|
||||
"alignment-baseline",
|
||||
"letter-spacing",
|
||||
"word-spacing",
|
||||
"text-decoration",
|
||||
"clip-path",
|
||||
"mask",
|
||||
"filter",
|
||||
"marker-start",
|
||||
"marker-mid",
|
||||
"marker-end",
|
||||
"marker",
|
||||
"cursor",
|
||||
"pointer-events",
|
||||
"visibility",
|
||||
"display",
|
||||
"overflow",
|
||||
"vector-effect",
|
||||
"paint-order",
|
||||
"color-interpolation",
|
||||
"color-interpolation-filters",
|
||||
"isolation",
|
||||
"mix-blend-mode",
|
||||
"startOffset",
|
||||
"method",
|
||||
"spacing",
|
||||
"side",
|
||||
"textLength",
|
||||
"lengthAdjust",
|
||||
"in",
|
||||
"in2",
|
||||
"result",
|
||||
"mode",
|
||||
"type",
|
||||
"values",
|
||||
"dx",
|
||||
"dy",
|
||||
"stdDeviation",
|
||||
"edgeMode",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"operator",
|
||||
"radius",
|
||||
"scale",
|
||||
"xChannelSelector",
|
||||
"yChannelSelector",
|
||||
"baseFrequency",
|
||||
"numOctaves",
|
||||
"seed",
|
||||
"stitchTiles",
|
||||
"tableValues",
|
||||
"slope",
|
||||
"intercept",
|
||||
"amplitude",
|
||||
"exponent",
|
||||
"order",
|
||||
"divisor",
|
||||
"bias",
|
||||
"targetX",
|
||||
"targetY",
|
||||
"kernelMatrix",
|
||||
"preserveAlpha",
|
||||
"kernelUnitLength",
|
||||
"surfaceScale",
|
||||
"diffuseConstant",
|
||||
"specularConstant",
|
||||
"specularExponent",
|
||||
"lighting-color",
|
||||
"flood-color",
|
||||
"flood-opacity",
|
||||
"azimuth",
|
||||
"elevation",
|
||||
"pointsAtX",
|
||||
"pointsAtY",
|
||||
"pointsAtZ",
|
||||
"limitingConeAngle",
|
||||
]);
|
||||
|
||||
function localName(name: string): string {
|
||||
const i = name.lastIndexOf(":");
|
||||
return i === -1 ? name : name.slice(i + 1);
|
||||
}
|
||||
|
||||
/** href/xlink:href (any prefix): a same-document fragment always, `data:image/` only off `<a>`
|
||||
* (an `<a>` navigates the top-level document on click; `<use>`/`<image>`/`<feImage>` only fetch
|
||||
* it as an inert image resource). */
|
||||
function isAllowedHref(value: string, tagLocalName: string): boolean {
|
||||
const v = value.trim();
|
||||
if (v.startsWith("#")) return true;
|
||||
return /^data:image\//i.test(v) && tagLocalName.toLowerCase() !== "a";
|
||||
}
|
||||
|
||||
/** CSS loads a resource only through a function (`url()`, `image-set()`, `src()`, ...) or
|
||||
* `@import`, so CSS text passes only if every function is on this compute-only list, every
|
||||
* `url()` is a same-document fragment, and there is no `@import`. `""` is a bare `(...)` group. */
|
||||
const SAFE_CSS_FUNCTIONS = new Set([
|
||||
"",
|
||||
"url",
|
||||
"rgb",
|
||||
"rgba",
|
||||
"hsl",
|
||||
"hsla",
|
||||
"hwb",
|
||||
"lab",
|
||||
"lch",
|
||||
"oklab",
|
||||
"oklch",
|
||||
"color",
|
||||
"color-mix",
|
||||
"var",
|
||||
"calc",
|
||||
"min",
|
||||
"max",
|
||||
"clamp",
|
||||
"matrix",
|
||||
"matrix3d",
|
||||
"translate",
|
||||
"translatex",
|
||||
"translatey",
|
||||
"translatez",
|
||||
"translate3d",
|
||||
"scale",
|
||||
"scalex",
|
||||
"scaley",
|
||||
"scalez",
|
||||
"scale3d",
|
||||
"rotate",
|
||||
"rotatex",
|
||||
"rotatey",
|
||||
"rotatez",
|
||||
"rotate3d",
|
||||
"skew",
|
||||
"skewx",
|
||||
"skewy",
|
||||
"perspective",
|
||||
"cubic-bezier",
|
||||
"steps",
|
||||
"blur",
|
||||
"brightness",
|
||||
"contrast",
|
||||
"drop-shadow",
|
||||
"grayscale",
|
||||
"hue-rotate",
|
||||
"invert",
|
||||
"opacity",
|
||||
"saturate",
|
||||
"sepia",
|
||||
]);
|
||||
|
||||
function decodeCssEscape(hex: string | undefined, char: string | undefined): string {
|
||||
if (hex === undefined) return char ?? "";
|
||||
const cp = parseInt(hex, 16);
|
||||
const invalid = cp === 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff);
|
||||
return invalid ? "�" : String.fromCodePoint(cp);
|
||||
}
|
||||
|
||||
// A browser reads `\75 rl(` as `url(`, so every check runs on escape-decoded, lowercased text.
|
||||
// Comments stay in: CSS never lets one join a function name or `@import`, only split tokens.
|
||||
function canonicalCss(text: string): string {
|
||||
return text
|
||||
.replace(/\\([0-9a-f]{1,6})[ \t\n\r\f]?|\\([\s\S])/gi, (_, hex, char) =>
|
||||
decodeCssEscape(hex, char),
|
||||
)
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// The scans below are single-pass indexOf loops, not regexes: the input is attacker-controlled
|
||||
// and a backtracking regex here stalled the CLI for seconds on a few KB of padding.
|
||||
function isCssNameChar(c: string | undefined): boolean {
|
||||
return c !== undefined && /[-\w]/.test(c);
|
||||
}
|
||||
|
||||
function functionNameBefore(css: string, paren: number): string {
|
||||
let start = paren;
|
||||
while (isCssNameChar(css[start - 1])) start--;
|
||||
return css.slice(start, paren);
|
||||
}
|
||||
|
||||
function everyFunctionIsSafe(css: string): boolean {
|
||||
for (let at = css.indexOf("("); at !== -1; at = css.indexOf("(", at + 1)) {
|
||||
if (!SAFE_CSS_FUNCTIONS.has(functionNameBefore(css, at))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A value holding "(" is rejected, so no second url( can hide inside the one being checked.
|
||||
function isLocalUrlValue(raw: string): boolean {
|
||||
const value = raw.trim().replace(/^['"]/, "");
|
||||
return value.startsWith("#") && !value.includes("(");
|
||||
}
|
||||
|
||||
function everyUrlIsLocalFragment(css: string): boolean {
|
||||
for (let at = css.indexOf("url("); at !== -1; ) {
|
||||
const close = css.indexOf(")", at + 4);
|
||||
if (close === -1 || !isLocalUrlValue(css.slice(at + 4, close))) return false;
|
||||
at = css.indexOf("url(", close + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSafeCss(text: string): boolean {
|
||||
const css = canonicalCss(text);
|
||||
return !css.includes("@import") && everyFunctionIsSafe(css) && everyUrlIsLocalFragment(css);
|
||||
}
|
||||
|
||||
function isXmlnsAttribute(name: string): boolean {
|
||||
return name === "xmlns" || name.startsWith("xmlns:");
|
||||
}
|
||||
|
||||
// aria-*/data-* are inert key/value pairs everywhere else; still CSS-checked below,
|
||||
// defensively, rather than trusted just because no browser reads CSS from them today.
|
||||
function isKnownAttribute(local: string): boolean {
|
||||
return SAFE_ATTRIBUTES.has(local) || local.startsWith("aria-") || local.startsWith("data-");
|
||||
}
|
||||
|
||||
// Every kept value goes through isSafeCss (style= and presentation attributes are CSS); a
|
||||
// non-CSS value that merely looks like a call, e.g. id="XRP-(XRP)", is dropped: fails closed.
|
||||
function isSafeAttribute(tagLocalName: string, name: string, value: string): boolean {
|
||||
if (isXmlnsAttribute(name)) return true;
|
||||
const local = localName(name);
|
||||
if (local === "href") return isAllowedHref(value, tagLocalName);
|
||||
return isKnownAttribute(local) && isSafeCss(value);
|
||||
}
|
||||
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
let out = svg;
|
||||
out = replaceStable(out, /<script\b[\s\S]*?<\/script\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<script\b[^>]*\/>/gi, "");
|
||||
out = replaceStable(out, /<style\b[\s\S]*?<\/style\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<foreignObject\b[\s\S]*?<\/foreignObject\b[^>]*>/gi, "");
|
||||
out = replaceStable(out, /<foreignObject\b[^>]*\/>/gi, "");
|
||||
// Nesting leaves inert orphan close tags after the stable pass — drop them.
|
||||
out = out.replace(/<\/(?:script|style|foreignObject)\b[^>]*>/gi, "");
|
||||
// on* handler attributes: quoted and unquoted forms.
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*"[^"]*"/gi, "");
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*'[^']*'/gi, "");
|
||||
out = replaceStable(out, /\son[a-z]+\s*=\s*[^\s>'"]+/gi, "");
|
||||
// href-like attributes: POSITIVE allowlist — keep only local fragments and
|
||||
// data:image embeds; drop everything else (javascript:, https?:, blob:,
|
||||
// vbscript:, data:text/html, protocol-relative, …).
|
||||
// xmlns declarations are attribute *names*, not href values — untouched.
|
||||
out = out.replace(/\s(href|xlink:href)\s*=\s*"([^"]*)"/gi, (m, attr: string, value: string) =>
|
||||
isAllowedHref(value) ? m : "",
|
||||
);
|
||||
out = out.replace(/\s(href|xlink:href)\s*=\s*'([^']*)'/gi, (m, attr: string, value: string) =>
|
||||
isAllowedHref(value) ? m : "",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
const root = doc.documentElement;
|
||||
if (!root) return "";
|
||||
|
||||
function isAllowedHref(value: string): boolean {
|
||||
const v = value.trim().toLowerCase();
|
||||
return v.startsWith("#") || v.startsWith("data:image/");
|
||||
for (const el of [...doc.querySelectorAll("*")]) {
|
||||
if (!el.isConnected) continue;
|
||||
if (!SAFE_ELEMENTS.has(el.tagName)) {
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
if (el.tagName === "style" && !isSafeCss(el.textContent)) {
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
for (const attr of [...el.attributes]) {
|
||||
if (!isSafeAttribute(el.tagName, attr.name, attr.value)) el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
return root.isConnected ? root.outerHTML : "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { generate } from "./generate-media-use-svg-sanitize.mjs";
|
||||
|
||||
// The single source of truth for SVG sanitization is packages/core/src/figma/sanitizeSvg.ts;
|
||||
// media-use's copy is a generated bundle (its test lane runs bare `node --test`, no install, so
|
||||
// it can't import linkedom directly). This is the trip wire against hand-editing the bundle or
|
||||
// forgetting to regenerate it after touching the source.
|
||||
describe("media-use svg-sanitize.mjs matches a fresh regeneration", () => {
|
||||
it("is byte-identical to `node scripts/generate-media-use-svg-sanitize.mjs`", async () => {
|
||||
const committed = readFileSync(
|
||||
resolve(import.meta.dirname, "../packages/cli/src/media-use/lib/svg-sanitize.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const fresh = await generate();
|
||||
assert.equal(
|
||||
committed,
|
||||
fresh,
|
||||
"packages/cli/src/media-use/lib/svg-sanitize.mjs is stale — run: node scripts/generate-media-use-svg-sanitize.mjs",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { sanitizeSvg as coreSanitizeSvg } from "../packages/core/src/figma/sanitizeSvg.ts";
|
||||
import { sanitizeSvg as mediaUseSanitizeSvg } from "../packages/cli/src/media-use/lib/svg-sanitize.mjs";
|
||||
|
||||
// svg-sanitize.mjs is generated from sanitizeSvg.ts (see check-media-use-svg-sanitize-generated
|
||||
// for the drift check). This is the behavioral trip wire: both copies must independently
|
||||
// neutralize and agree on every payload, so a bad regeneration can't silently reopen a bypass.
|
||||
const HOSTILE_CORPUS = [
|
||||
["<svg><script>alert(1)</script><rect/></svg>", "script element"],
|
||||
['<svg><script src="//evil"/></svg>', "self-closing script"],
|
||||
['<svg><style>@import "//evil";</style></svg>', "style block"],
|
||||
['<svg><foreignObject><body onload="alert(1)"/></foreignObject></svg>', "foreignObject subtree"],
|
||||
["<svg><foreignObject/></svg>", "self-closing foreignObject"],
|
||||
['<svg><rect onload="alert(1)"/></svg>', "quoted on* handler"],
|
||||
[`<svg><rect onload='alert(1)'/></svg>`, "single-quoted on* handler"],
|
||||
["<svg><rect onload=alert(1)/></svg>", "unquoted on* handler"],
|
||||
['<svg><a href="javascript:alert(1)"><rect/></a></svg>', "javascript: href"],
|
||||
['<svg><a xlink:href="javascript:alert(1)"><rect/></a></svg>', "javascript: xlink:href"],
|
||||
[
|
||||
'<svg><a href="data:image/png;base64,AA=="><rect/></a></svg>',
|
||||
"data:image/ href on <a> (still navigable, dropped)",
|
||||
],
|
||||
['<svg><use href="data:image/png;base64,AA=="/></svg>', "allowed data:image/ href on non-<a>"],
|
||||
['<svg><a href="#clip"><rect/></a></svg>', "allowed local fragment href"],
|
||||
['<svg><a href="data:text/html,<script>1</script>"><rect/></a></svg>', "data:text/html href"],
|
||||
[
|
||||
"<svg><script>a</script><script>/*<script>*/nested()</script></svg>",
|
||||
"nested/overlapping script tags",
|
||||
],
|
||||
[
|
||||
"<svg><foreignObject><foreignObject><script>1</script></foreignObject></foreignObject></svg>",
|
||||
"nested foreignObject",
|
||||
],
|
||||
[
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><svg><rect onclick="a"/></svg></svg>',
|
||||
"nested svg element",
|
||||
],
|
||||
["<svg><rect onMouseOver=\"alert('x')\" ONCLICK='y'/></svg>", "mixed-case on* handler names"],
|
||||
['<svg><a HREF="javascript:alert(1)"><rect/></a></svg>', "mixed-case href attribute name"],
|
||||
["<svg><a href=javascript:alert(1)><rect/></a></svg>", "unquoted javascript: href"],
|
||||
["<svg><use href=https://evil.example/x.svg#p/></svg>", "unquoted external use href"],
|
||||
['<svg><rect style="fill:url(https://evil.example/x)"/></svg>', "style url() exfil"],
|
||||
['<svg><rect style="background:@import url(evil)"/></svg>', "style @import exfil"],
|
||||
['<svg><rect style="width:expression(alert(1))"/></svg>', "style expression() exfil"],
|
||||
[`<svg><rect style="background:url('javascript:alert(1)')"/></svg>`, "style javascript: exfil"],
|
||||
['<svg><rect style="fill:#123456"/></svg>', "benign style attribute survives"],
|
||||
[
|
||||
'<svg><path clip-path="url(#clip0)" fill="url(#grad)"/></svg>',
|
||||
"url(#id) on non-style attrs survives",
|
||||
],
|
||||
[
|
||||
'<svg xmlns:x="http://www.w3.org/1999/xlink"><use x:href="https://evil.example/x.svg#p"/></svg>',
|
||||
"href via a renamed xlink namespace prefix",
|
||||
],
|
||||
['<svg><rect fill="url(https://evil.example/x.svg#p)"/></svg>', "fill url() exfil"],
|
||||
['<svg><rect filter="url(https://evil.example/x.svg#f)"/></svg>', "filter url() exfil"],
|
||||
['<svg><rect mask="url(https://evil.example/x.svg#m)"/></svg>', "mask url() exfil"],
|
||||
['<svg><rect clip-path="url(https://evil.example/x.svg#c)"/></svg>', "clip-path url() exfil"],
|
||||
[
|
||||
'<svg><path marker-start="url(https://evil.example/x.svg#m)"/></svg>',
|
||||
"marker-start url() exfil",
|
||||
],
|
||||
[
|
||||
"<svg><rect style=x=y;fill:url(https://evil.example/beacon)>r</rect></svg>",
|
||||
"unquoted attribute value containing '='",
|
||||
],
|
||||
[
|
||||
'<svg><a href="#a"><animate attributeName="href" to="javascript:alert(1)"/></a></svg>',
|
||||
"SMIL <animate> rewrites href to javascript:",
|
||||
],
|
||||
[
|
||||
'<svg><a href="#a"><set attributeName="href" to="javascript:alert(1)"/></a></svg>',
|
||||
"SMIL <set> rewrites href to javascript:",
|
||||
],
|
||||
[
|
||||
'<svg><a href="data:image/svg+xml;base64,PHN2Zz48c2NyaXB0PmFsZXJ0KDEpPC9zY3JpcHQ+PC9zdmc+">click</a></svg>',
|
||||
"data:image/svg+xml navigation on <a>",
|
||||
],
|
||||
[
|
||||
String.raw`<svg><rect style="fill:\75 rl(https://evil.example/b)"/></svg>`,
|
||||
"CSS-escaped url() in style=",
|
||||
],
|
||||
[
|
||||
String.raw`<svg><rect fill="\75rl(https://evil.example/b)"/></svg>`,
|
||||
"CSS-escaped url() in fill",
|
||||
],
|
||||
[
|
||||
String.raw`<svg><style>@\69mport 'https://evil.example/x.css';</style></svg>`,
|
||||
"CSS-escaped @import in <style>",
|
||||
],
|
||||
[
|
||||
`<svg><style>svg{background-image:image-set("https://evil.example/b" 1x)}</style></svg>`,
|
||||
"image-set() string URL in <style>",
|
||||
],
|
||||
];
|
||||
|
||||
// The attacker host is checked as plain text so the oracle can't share a CSS-parsing blind
|
||||
// spot with the sanitizer: however a payload spells its URL, the host must not survive.
|
||||
const UNSAFE_MARKERS = [
|
||||
[/<script\b/i, "<script survived"],
|
||||
[/<foreignobject\b/i, "<foreignObject survived"],
|
||||
[/\son\w+\s*=/i, "on* handler survived"],
|
||||
[/javascript:/i, "javascript: survived"],
|
||||
[/evil\.example/i, "attacker host survived"],
|
||||
];
|
||||
|
||||
function hasDataImageOnAnchor(out) {
|
||||
return /data:image/i.test(out) && /<a[\s>]/i.test(out);
|
||||
}
|
||||
|
||||
function nonLocalUrls(out) {
|
||||
return [...out.matchAll(/url\(\s*(['"]?)([^)]*)\1\s*\)/gi)]
|
||||
.map((m) => m[2])
|
||||
.filter((url) => !url.trim().startsWith("#"));
|
||||
}
|
||||
|
||||
// Prefixed with `${name}/${label}` so a failure names the copy and the payload that leaked.
|
||||
function survivingUnsafeMarkers(name, label, out) {
|
||||
const found = UNSAFE_MARKERS.filter(([re]) => re.test(out)).map(([, message]) => message);
|
||||
if (hasDataImageOnAnchor(out)) found.push("data:image href survived on <a>");
|
||||
for (const url of nonLocalUrls(out)) found.push(`non-local url(${url}) survived`);
|
||||
return found.map((message) => `${name}/${label}: ${message}`);
|
||||
}
|
||||
|
||||
describe("svg-sanitize parity: core vs media-use (generated) port", () => {
|
||||
for (const [input, label] of HOSTILE_CORPUS) {
|
||||
it(`matches core output for: ${label}`, () => {
|
||||
assert.equal(mediaUseSanitizeSvg(input), coreSanitizeSvg(input));
|
||||
});
|
||||
}
|
||||
|
||||
it("both copies independently reject every unsafe marker in the corpus", () => {
|
||||
const failures = [];
|
||||
for (const [input, label] of HOSTILE_CORPUS) {
|
||||
for (const [name, sanitize] of [
|
||||
["core", coreSanitizeSvg],
|
||||
["media-use", mediaUseSanitizeSvg],
|
||||
]) {
|
||||
failures.push(...survivingUnsafeMarkers(name, label, sanitize(input)));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Bundles packages/core/src/figma/sanitizeSvg.ts (linkedom and all) into a single
|
||||
// import-free .mjs so media-use's zero-install test lane can still run it under plain `node`.
|
||||
// Regenerate after editing the source: node scripts/generate-media-use-svg-sanitize.mjs
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY = resolve(here, "../packages/core/src/figma/sanitizeSvg.ts");
|
||||
const OUTFILE = resolve(here, "../packages/cli/src/media-use/lib/svg-sanitize.mjs");
|
||||
// scripts/ has no node_modules of its own (bun's per-consumer linking); reach into the one
|
||||
// package that already depends on esbuild rather than adding a root-level dependency.
|
||||
const { build } = await import(resolve(here, "../packages/core/node_modules/esbuild/lib/main.js"));
|
||||
|
||||
const HEADER = `// GENERATED — do not hand-edit. Source: packages/core/src/figma/sanitizeSvg.ts, bundled by
|
||||
// scripts/generate-media-use-svg-sanitize.mjs so media-use's zero-install \`node --test\` lane can
|
||||
// still run the real sanitizer. Regenerate: node scripts/generate-media-use-svg-sanitize.mjs
|
||||
`;
|
||||
|
||||
export async function generate() {
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
write: false,
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
});
|
||||
return HEADER + result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const code = await generate();
|
||||
writeFileSync(OUTFILE, code);
|
||||
console.log(`wrote ${OUTFILE} (${code.length} bytes)`);
|
||||
}
|
||||
@@ -54,11 +54,11 @@
|
||||
"files": 1
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "7263711f70b299d6",
|
||||
"hash": "d64b6d419acfee84",
|
||||
"files": 90
|
||||
},
|
||||
"motion-graphics": {
|
||||
"hash": "9c2ca2e8c384875d",
|
||||
"hash": "ab9d2e9c205ce1b1",
|
||||
"files": 23
|
||||
},
|
||||
"music-to-video": {
|
||||
|
||||
+10
-10
@@ -17,16 +17,16 @@ npx hyperframes media-use resolve --type <type> --intent "<description>" --proje
|
||||
|
||||
Returns one line: `resolved <id> → <path> (<type>, <metadata>)`. All search noise stays on disk.
|
||||
|
||||
| Type | One-line intent |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| `bgm` | background music (HeyGen catalog, 10k+ tracks) |
|
||||
| `sfx` | sound effects (bundled 19-file library + catalog) |
|
||||
| `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) |
|
||||
| `icon` | icons, symbols (transparent) |
|
||||
| `logo` | official brand marks (svgl → simple-icons → GitHub avatar → favicon; never redrawn) |
|
||||
| `voice` | TTS voiceover (HeyGen free-usage path; optional local Kokoro) |
|
||||
| `grade` | measured correction candidate; broad polish/stylization follows Media Treatments |
|
||||
| `lut` | user-provided or explicitly chosen reusable validated `.cube` file |
|
||||
| Type | One-line intent |
|
||||
| ------- | -------------------------------------------------------------------------------- |
|
||||
| `bgm` | background music (HeyGen catalog, 10k+ tracks) |
|
||||
| `sfx` | sound effects (bundled 19-file library + catalog) |
|
||||
| `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) |
|
||||
| `icon` | icons, symbols (transparent) |
|
||||
| `logo` | official brand marks (theSVG → GitHub avatar → favicon; never redrawn) |
|
||||
| `voice` | TTS voiceover (HeyGen free-usage path; optional local Kokoro) |
|
||||
| `grade` | measured correction candidate; broad polish/stylization follows Media Treatments |
|
||||
| `lut` | user-provided or explicitly chosen reusable validated `.cube` file |
|
||||
|
||||
Before resolving fresh, list reusable candidates with `--candidates` and judge fit yourself — reuse rules, all flags, ingest (`--from`), and adopt are in `references/resolve.md`.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ HyperFrames owns media _playback_; media-use owns everything else. Each row is e
|
||||
| HyperFrames gap | media-use owns it via |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
|
||||
| No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) |
|
||||
| No third-party brand logos | `resolve --type logo` (theSVG → GitHub org avatar → domain favicon) |
|
||||
| No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) |
|
||||
| Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
|
||||
| No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
|
||||
|
||||
@@ -14,7 +14,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
|
||||
| `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
|
||||
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
|
||||
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
|
||||
| `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
|
||||
| `logo` | Official brand marks | theSVG → GitHub org avatar → domain favicon |
|
||||
| `voice` | TTS voiceover | HeyGen TTS free-usage path; optional local Kokoro |
|
||||
| `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube |
|
||||
| `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube |
|
||||
|
||||
@@ -31,7 +31,7 @@ see the ladder and override.
|
||||
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
|
||||
| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
|
||||
| icon | heygen asset search free-usage path |
|
||||
| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) |
|
||||
| logo | theSVG (thesvg.org), then GitHub org avatar, then domain favicon (all free) |
|
||||
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
|
||||
| video | heygen avatar video free-usage path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Two layouts share that one highlight core — pick by aspect + whether there's a
|
||||
|
||||
## Source (Step 2)
|
||||
|
||||
RWA / web search (or `hyperframes capture`) → a real article. The Director extracts the **keyword** (1–2 words / a number / a name — the hook) and, for Layout B, also a **brand logo**, a **date**, and a **person photo**. `asset_needs: { kind: news|web|image, query }` — request the logo (Wikimedia / simple-icons) and the person photo (Wikimedia) as separate asset queries. Run the person photo through `hyperframes remove-background <in> -o <out>.png` to get a transparent cutout.
|
||||
RWA / web search (or `hyperframes capture`) → a real article. The Director extracts the **keyword** (1–2 words / a number / a name — the hook) and, for Layout B, also a **brand logo**, a **date**, and a **person photo**. `asset_needs: { kind: news|web|image, query }` — request the logo (`media-use resolve --type logo`, theSVG-first) and the person photo (Wikimedia) as separate asset queries. Run the person photo through `hyperframes remove-background <in> -o <out>.png` to get a transparent cutout.
|
||||
|
||||
## The highlight core (shared by both layouts) — the marker-band technique
|
||||
|
||||
|
||||
Reference in New Issue
Block a user