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.
scout/forge:scout/11020-every-file-renderer-survives-hostile-inp → main 1 commit fork
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 | ")", | |
| 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 | "<script>", | |
| 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 .\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(/:/gi, ":") | |
| 187 | .replace(/&tab;|&newline;/gi, "") | |
| 188 | .replace(/&/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="javascript:a()">x</a>')).toThrow(); | |
| 211 | expect(() => assertInert("<p><img onerror=a()> 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 | } |