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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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, poster or data attribute whose value starts with javascript: or vbscript:, 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 &lt;img onerror=…&gt;, 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 in test/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.ts is 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_plan tool, 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

commented

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, poster or data attribute whose value starts with javascript: or vbscript:, 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 &lt;img onerror=…&gt;, 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 in test/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.ts is 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_plan tool, 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

Approved

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).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs