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

scout/forge:scout/11020-every-file-renderer-survives-hostile-inp → main 1 commit fork

  • Merged. scout/11020-every-file-renderer-survives-hostile-inp was merged into main as c02ee65.

Review

Sign in to review this change.
1 file changed +256 −0
test/fuzz-renderers.test.ts added +256
@@ -0,0 +1,256 @@
1 /**
2 * The file renderers that draw text a stranger pushed, fed a generator's
3 * cases: valid files of each kind spliced with markup, `javascript:` URLs,
4 * NUL, lone surrogates and very long lines. Every case must render without
5 * throwing, and the HTML it gives must carry no `<script`, no `on…=` event
6 * attribute and no `href`/`src`/`action` that runs `javascript:`. Seeded
7 * (`FUZZ_SEED`, `FUZZ_RUNS`): a failure prints the seed and the shrunk case,
8 * to be kept in test/fuzz-found.test.ts.
9 */
10 import { describe, expect, test } from "bun:test";
11 import type { Renderer } from "../src/web/render/renderers.ts";
12 import { csv } from "../src/web/render/csv.ts";
13 import { log } from "../src/web/render/log.ts";
14 import { ics } from "../src/web/render/ics.ts";
15 import { vcf } from "../src/web/render/vcf.ts";
16 import { gpx } from "../src/web/render/gpx.ts";
17 import { geojson } from "../src/web/render/geojson.ts";
18 import { subtitles } from "../src/web/render/subtitles.ts";
19 import { patch } from "../src/web/render/patch.ts";
20 import { notebook } from "../src/web/render/notebook.ts";
21 import { gcode } from "../src/web/render/gcode.ts";
22 import { config } from "../src/web/render/config.ts";
23 import { markdown } from "../src/web/render/markdown.ts";
24 import { fuzz, int, mix, pick, type Rand } from "./helpers/fuzz.ts";
25
26 /** What a hostile file tries to smuggle into the page. */
27 export const HOSTILE = [
28 "<script>alert(1)</script>",
29 "<ScRiPt src=//x.test/a.js>",
30 '"><img src=x onerror=alert(1)>',
31 "'><svg onload=alert(1)>",
32 "javascript:alert(1)",
33 " JaVaScRiPt:alert(1)",
34 "[x](javascript:alert(1))",
35 "![x](javascript:alert(1))",
36 "<a href=\"javascript:alert(1)\">x</a>",
37 "<iframe src=javascript:alert(1)>",
38 "\u0000",
39 "\ud800",
40 "\udfff",
41 "\u001b[31m",
42 "\u001b]8;;javascript:alert(1)\u0007x\u001b]8;;\u0007",
43 "&lt;script&gt;",
44 "\r\n",
45 "\n",
46 "\t",
47 ",",
48 '"',
49 "'",
50 "<",
51 ">",
52 "&",
53 "x".repeat(5000),
54 ];
55
56 /** A valid file of each kind; a case is one of these with hostile text spliced in. */
57 const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly string[] }> = {
58 csv: {
59 path: "data.csv",
60 render: csv,
61 files: ['name,url,note\nAda,https://a.test,"quoted, cell"\nBob,javascript:alert(1),x\n'],
62 },
63 tsv: { path: "data.tsv", render: csv, files: ["a\tb\tc\n1\t2\t3\n"] },
64 jsonl: {
65 path: "rows.jsonl",
66 render: csv,
67 files: ['{"a":1,"b":"<b>x</b>"}\n{"a":2,"href":"javascript:alert(1)"}\n'],
68 },
69 log: {
70 path: "build.log",
71 render: log,
72 files: [
73 "\u001b[1;32mok\u001b[0m step one\n\u001b[38;5;196mfail\u001b[0m\n\u001b]8;;https://a.test\u0007link\u001b]8;;\u0007\n",
74 ],
75 },
76 ics: {
77 path: "cal.ics",
78 render: ics,
79 files: [
80 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Meet\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nLOCATION:Room 1\r\nURL:https://a.test/\r\nDESCRIPTION:Line\\nTwo\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
81 ],
82 },
83 vcf: {
84 path: "people.vcf",
85 render: vcf,
86 files: [
87 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEMAIL:[email protected]\r\nTEL:+1 555\r\nURL:https://a.test/\r\nORG:Engines\r\nNOTE:hi\r\nEND:VCARD\r\n",
88 ],
89 },
90 gpx: {
91 path: "track.gpx",
92 render: gpx,
93 files: [
94 '<?xml version="1.0"?><gpx version="1.1" creator="x"><metadata><name>Walk</name><link href="https://a.test"/></metadata><trk><name>T</name><trkseg><trkpt lat="51.5" lon="-0.1"><ele>10</ele><time>2026-01-01T10:00:00Z</time></trkpt><trkpt lat="51.6" lon="-0.2"><ele>12</ele></trkpt></trkseg></trk><wpt lat="51.5" lon="-0.1"><name>W</name><desc>d</desc></wpt></gpx>',
95 ],
96 },
97 geojson: {
98 path: "map.geojson",
99 render: geojson,
100 files: [
101 '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"A","url":"javascript:alert(1)"},"geometry":{"type":"Point","coordinates":[0.1,51.5]}},{"type":"Feature","properties":{"k":"v"},"geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}]}',
102 ],
103 },
104 srt: {
105 path: "talk.srt",
106 render: subtitles,
107 files: ["1\n00:00:01,000 --> 00:00:02,000\n<i>Hello</i> there\n\n2\n00:00:03,000 --> 00:00:04,500\n<b>Bye</b>\n"],
108 },
109 vtt: {
110 path: "talk.vtt",
111 render: subtitles,
112 files: ["WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<v Ada>Hello <c.x>there</c>\n\nNOTE x\n\n00:00:03.000 --> 00:00:04.000 align:start\nBye\n"],
113 },
114 patch: {
115 path: "fix.patch",
116 render: patch,
117 files: [
118 "From abc Mon Sep 17 00:00:00 2001\nFrom: Ada <[email protected]>\nSubject: [PATCH] fix\n\n---\ndiff --git a/x.ts b/x.ts\nindex 1..2 100644\n--- a/x.ts\n+++ b/x.ts\n@@ -1,2 +1,2 @@\n-old\n+new\n context\n",
119 ],
120 },
121 notebook: {
122 path: "nb.ipynb",
123 render: notebook,
124 files: [
125 '{"nbformat":4,"nbformat_minor":5,"metadata":{"kernelspec":{"name":"python3","language":"python"}},"cells":[{"cell_type":"markdown","source":["# Title\\n","[x](https://a.test)"]},{"cell_type":"code","execution_count":1,"source":"print(1)","outputs":[{"output_type":"stream","name":"stdout","text":["1\\n"]},{"output_type":"execute_result","data":{"text/html":["<b>x</b>"],"text/plain":["x"],"image/png":"iVBORw0KGgo="}},{"output_type":"error","ename":"E","evalue":"v","traceback":["\\u001b[31mE\\u001b[0m"]}]}]}',
126 ],
127 },
128 gcode: {
129 path: "part.gcode",
130 render: gcode,
131 files: [";FLAVOR:Marlin\n;LAYER:0\nG28\nG1 Z0.2 F300\nG1 X10 Y10 E1 F1200\nG1 X20 Y10 E2\n;LAYER:1\nG1 Z0.4\nG1 X20 Y20 E3\nM104 S200 ; <b>hot</b>\n"],
132 },
133 json: {
134 path: "settings.json",
135 render: config,
136 files: ['{"name":"x","port":8080,"debug":true,"tags":["a","b"],"nested":{"url":"javascript:alert(1)","n":null}}'],
137 },
138 yaml: {
139 path: "settings.yaml",
140 render: config,
141 files: ["name: x\nport: 8080\ntags:\n - a\n - b\nnested:\n url: \"javascript:alert(1)\"\n"],
142 },
143 toml: {
144 path: "settings.toml",
145 render: config,
146 files: ['name = "x"\nport = 8080\n[nested]\nurl = "javascript:alert(1)"\n'],
147 },
148 markdown: {
149 path: "docs/README.md",
150 render: markdown,
151 files: [
152 "# Title\n\nSome *text* and a [link](https://a.test) and ![img](pic.png).\n\n```js\ncode()\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n<details><summary>x</summary>y</details>\n",
153 ],
154 },
155 };
156
157 /** One case: a sample cut at random places with hostile pieces put in. */
158 const caseFor =
159 (files: readonly string[]) =>
160 (r: Rand): string => {
161 let text = pick(r, files);
162 if (r() < 0.1) return mix(r, HOSTILE, 20);
163 for (let n = int(r, 6) + 1; n > 0; n--) {
164 const at = int(r, text.length + 1);
165 const cut = r() < 0.2 ? int(r, 8) : 0;
166 text = text.slice(0, at) + mix(r, HOSTILE, 3) + text.slice(at + cut);
167 }
168 return text;
169 };
170
171 /** Each tag's attributes as [name, value], read the way a browser reads them. */
172 export function attributes(html: string): [string, string][] {
173 const out: [string, string][] = [];
174 for (const tag of html.matchAll(/<[a-zA-Z][^\s/>]*([^>]*)>/g)) {
175 const rest = tag[1] ?? "";
176 for (const a of rest.matchAll(/([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g))
177 out.push([a[1]!.toLowerCase(), a[2] ?? a[3] ?? a[4] ?? ""]);
178 }
179 return out;
180 }
181
182 const decodeEntities = (v: string): string =>
183 v
184 .replace(/&#x([0-9a-f]+);?/gi, (_, h: string) => String.fromCodePoint(Math.min(parseInt(h, 16), 0x10ffff)))
185 .replace(/&#(\d+);?/g, (_, d: string) => String.fromCodePoint(Math.min(Number(d), 0x10ffff)))
186 .replace(/&colon;/gi, ":")
187 .replace(/&tab;|&newline;/gi, "")
188 .replace(/&amp;/g, "&");
189
190 /** Throws when rendered HTML would run script in a reader's browser. */
191 export function assertInert(html: string): void {
192 if (/<script/i.test(html)) throw new Error("raw <script in output");
193 for (const [name, value] of attributes(html)) {
194 if (/^on[a-z]+$/.test(name)) throw new Error(`event attribute ${name}= in output`);
195 if (/^(href|src|action|formaction|xlink:href|poster|data)$/.test(name)) {
196 const url = decodeEntities(value).replace(/[\u0000- ]/g, "").toLowerCase();
197 if (url.startsWith("javascript:") || url.startsWith("vbscript:"))
198 throw new Error(`${name}="${value}" runs script`);
199 }
200 }
201 }
202
203 const encoder = new TextEncoder();
204
205 describe("fuzz: file renderers", () => {
206 test("the inertness check catches what it is for", () => {
207 expect(() => assertInert("<script>x</script>")).toThrow();
208 expect(() => assertInert('<img src=x onerror="a()">')).toThrow();
209 expect(() => assertInert("<a href=' javascript:a()'>x</a>")).toThrow();
210 expect(() => assertInert('<a href="java&#x73;cript:a()">x</a>')).toThrow();
211 expect(() => assertInert("<p>&lt;img onerror=a()&gt; javascript:a()</p>")).not.toThrow();
212 expect(() => assertInert('<a title="x onerror=y" href="https://a.test">x</a>')).not.toThrow();
213 });
214
215 test("a renderer that forgets to escape is caught, with its seed", async () => {
216 const leaky = (text: string) => assertInert(`<pre>${text}</pre>`);
217 await expect(fuzz("renderer leaky", caseFor(SAMPLES["csv"]!.files), leaky, 100)).rejects.toThrow(
218 /FUZZ_SEED=\d+ case \d+\n {2}minimal: /,
219 );
220 });
221
222 for (const [name, sample] of Object.entries(SAMPLES))
223 test(`${name}: never throws, never lets a tag or a javascript: URL through`, async () => {
224 // The valid sample itself draws, so the cases built on it reach the drawing.
225 for (const file of sample.files) {
226 const out = sample.render(input(sample.path, file));
227 expect(out.content.text.length).toBeGreaterThan(0);
228 }
229 await fuzz(
230 `renderer ${name}`,
231 caseFor(sample.files),
232 (text) => {
233 const all = text.length % 2 === 0;
234 const out = sample.render(input(sample.path, text, all));
235 assertInert(out.content.text);
236 },
237 Math.min(Number(process.env["FUZZ_RUNS"] ?? 250), 250),
238 );
239 });
240 });
241
242 function input(path: string, text: string, all = false) {
243 const body = encoder.encode(text);
244 return {
245 path,
246 size: body.length,
247 body,
248 media: `/o/r/media/main/${path}`,
249 download: `/o/r/media/main/${path}?download=1`,
250 pathHref: `/o/r/blob/main/${path}`,
251 repo: { base: "/o/r", ref: "main" },
252 all,
253 table: "<script>",
254 layer: 2,
255 };
256 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs