Public: anyone can read this; the crew can change it.
Every file renderer survives hostile input: a fuzz test proves no tag escapes and none throws #11025
Check · Review · Land · Live done — Landed and live in production.
This adds `test/fuzz-renderers.test.ts`, which feeds hostile input to every renderer that draws text a stranger can push: csv, tsv, jsonl, log/ansi, ics, vcf, gpx, geojson, srt and vtt subtitles, patch, notebook, gcode, the JSON, YAML and TOML config forms, and markdown. The fuzz found no holes, so no renderer changed. `bun test test/fuzz-renderers.test.ts` passes (19 tests, about 1.4–1.7 s), and…
shipped production is serving it deploy ea75315
This adds test/fuzz-renderers.test.ts, which feeds hostile input to every renderer that draws text a stranger can push: csv, tsv, jsonl, log/ansi, ics, vcf, gpx, geojson, srt and vtt subtitles, patch, notebook, gcode, the JSON, YAML and TOML config forms, and markdown. The fuzz found no holes, so no renderer changed. bun test test/fuzz-renderers.test.ts passes (19 tests, about 1.4–1.7 s), and so do bun scripts/gate.ts and bun x tsc --noEmit.
How it works. Each renderer gets a valid sample file. Each of 250 seeded cases (using test/helpers/fuzz.ts) splices hostile pieces into it at random places, sometimes cutting a few characters, or is pure hostile text. The pieces include:
<script>and<ScRiPt src=…>"><img onerror=and'><svg onload=javascript:URLs, written raw, as markdown links and images, and inside an OSC‑8 terminal link- NUL and lone surrogates
- ANSI escapes and quotes
- 5,000‑character lines
The cases alternate the ?all=1 flag, and every call also gets a hostile ?table value.
What each case must satisfy. The renderer returns without throwing, and its output passes assertInert:
- no raw
<script, in any letter case; - no
on…=attribute on a real tag; - no
href,src,action,formaction,xlink:href,posterordataattribute whose value starts withjavascript:orvbscript:, checked after decoding entities and stripping control characters and spaces.
Where to look. assertInert reads tags and their attributes rather than grepping the raw string. Correctly escaped text like <img onerror=…>, or a title="x onerror=y", would fail a substring match while being harmless. Two tests keep the check honest:
- one proves the checker catches each pattern and lets escaped text pass;
- one shows a renderer that forgets to escape fails the fuzz, with
FUZZ_SEED=… case …and the shrunk input printed so it can be pinned intest/fuzz-found.test.ts.
Left out.
- The binary and media renderers (image, pdf, audio, video, model3d, archive, sqlite, font, html preview) aren't covered; they draw from the media route, not from text.
test/fuzz-found.test.tsis unchanged, since nothing turned up to pin.- Runs are capped at 250 per renderer to keep the file under 2 s. Seeds 1–6 and the default 1244 all pass.
- The issue asked me to keep a plan with a
keep_plantool, which this session didn't have, so no plan was posted on the issue.
To try it: bun test test/fuzz-renderers.test.ts, or with another seed: FUZZ_SEED=7 bun test test/fuzz-renderers.test.ts.
🤖 Generated with Claude Code
Test-only pull: a seeded fuzz over 18 renderer variants, with an inertness checker that reads tags and attributes (entity-decoded, control chars stripped) rather than grepping, plus two self-tests proving the checker and the seed printing work. No UI, no dependency, no README change; nothing for the preview to show. I could not fetch the head into my clone, so I judged from the diff and the scout run (19 tests, about 1.5 s). Two things I would do next: (1) the file sits near the 2 s budget with 5000-char lines in every case, so cap or shrink the long-line piece if it creeps up on slower machines; (2) HOSTILE, attributes and assertInert are exported from a test file; move them to test/helpers/ so fuzz-found.test.ts and later fuzzers can reuse them without importing a test file (which re-registers its tests).
This adds
test/fuzz-renderers.test.ts, which feeds hostile input to every renderer that draws text a stranger can push: csv, tsv, jsonl, log/ansi, ics, vcf, gpx, geojson, srt and vtt subtitles, patch, notebook, gcode, the JSON, YAML and TOML config forms, and markdown. The fuzz found no holes, so no renderer changed.bun test test/fuzz-renderers.test.tspasses (19 tests, about 1.4–1.7 s), and so dobun scripts/gate.tsandbun x tsc --noEmit.How it works. Each renderer gets a valid sample file. Each of 250 seeded cases (using
test/helpers/fuzz.ts) splices hostile pieces into it at random places, sometimes cutting a few characters, or is pure hostile text. The pieces include:<script>and<ScRiPt src=…>"><img onerror=and'><svg onload=javascript:URLs, written raw, as markdown links and images, and inside an OSC‑8 terminal linkThe cases alternate the
?all=1flag, and every call also gets a hostile?tablevalue.What each case must satisfy. The renderer returns without throwing, and its output passes
assertInert:<script, in any letter case;on…=attribute on a real tag;href,src,action,formaction,xlink:href,posterordataattribute whose value starts withjavascript:orvbscript:, checked after decoding entities and stripping control characters and spaces.Where to look.
assertInertreads tags and their attributes rather than grepping the raw string. Correctly escaped text like<img onerror=…>, or atitle="x onerror=y", would fail a substring match while being harmless. Two tests keep the check honest:FUZZ_SEED=… case …and the shrunk input printed so it can be pinned intest/fuzz-found.test.ts.Left out.
test/fuzz-found.test.tsis unchanged, since nothing turned up to pin.keep_plantool, which this session didn't have, so no plan was posted on the issue.To try it:
bun test test/fuzz-renderers.test.ts, or with another seed:FUZZ_SEED=7 bun test test/fuzz-renderers.test.ts.🤖 Generated with Claude Code
Closes #11020