Public: anyone can read this; the crew can change it.
A hostile listing cannot break any page: every typed field escaped on every surface, proved by one test #18
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/15-a-hostile-listing-cannot-break-any-page → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +215
−4
src/hostile.test.ts added +191
| @@ -0,0 +1,191 @@ | ||
| 1 | /** | |
| 2 | * One listing whose every typed word is hostile, fetched from every surface | |
| 3 | * that shows a listing. Typed words stay as typed; every output escapes them. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { serve } from "./server.ts"; | |
| 11 | import { sign } from "./who.ts"; | |
| 12 | ||
| 13 | const KEY = "a-key-only-the-forge-holds"; | |
| 14 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "hostile-")), identityKey: KEY }); | |
| 15 | const base = `http://${server.hostname}:${server.port}`; | |
| 16 | afterAll(() => server.stop(true)); | |
| 17 | ||
| 18 | const identity = `hostile-author|<b>Re"al'tor</b>|user|${Date.now() + 600_000}`; | |
| 19 | const headers = { | |
| 20 | "x-forge-identity": identity, | |
| 21 | "x-forge-signature": sign(identity, KEY), | |
| 22 | }; | |
| 23 | ||
| 24 | const BIDI = ""; | |
| 25 | const PAYLOADS = { | |
| 26 | address: `1 <script>alert(1)</script> "><img src=x onerror=alert(2)> ${BIDI}😈`, | |
| 27 | feature: `<svg/onload=alert(3)> '" onfocus="alert(4)" javascript:alert(5) </textarea><script>x</script>`, | |
| 28 | agent_name: `<script>alert(6)</script>" autofocus onfocus="alert(7) ${BIDI}`, | |
| 29 | brokerage: `'><iframe src="javascript:alert(8)"></iframe> 🏠`, | |
| 30 | license: `"><script src=//evil/x.js></script>`, | |
| 31 | agent_email: `javascript:"onmouseover="alert(9)"<[email protected]`.replace("<", ""), | |
| 32 | asset: `</textarea><script>alert(10)</script><a href="javascript:alert(11)">x</a>\n\nSubject: <b onclick="alert(12)">hi</b>\n\n'"&${BIDI}😈`, | |
| 33 | }; | |
| 34 | // What must never reach a page as markup: the characters that open one. | |
| 35 | const RAW = [ | |
| 36 | "<script>alert", "<img src=x", "<svg/onload", "<iframe", "</textarea><script", "<a href=\"javascript", | |
| 37 | "<b onclick", "<script src=//evil", "<b>Re", "<acre>", | |
| 38 | ]; | |
| 39 | ||
| 40 | const form = new FormData(); | |
| 41 | form.set("address", PAYLOADS.address); | |
| 42 | for (const [k, v] of Object.entries({ beds: 3, baths: 2, sqft: 1800, lot: "0.2 <acre>", year: 1999, | |
| 43 | price: 500000, tone: "classic", palette: "navy", agent_phone: "+1 (503) 555-0100" })) | |
| 44 | form.set(k, String(v)); | |
| 45 | form.set("features", [1, 2, 3, 4, 5].map((n) => `${PAYLOADS.feature} ${n}`).join("\n")); | |
| 46 | form.set("agent_name", PAYLOADS.agent_name); | |
| 47 | form.set("brokerage", PAYLOADS.brokerage); | |
| 48 | form.set("license", PAYLOADS.license); | |
| 49 | form.set("agent_email", PAYLOADS.agent_email); | |
| 50 | ||
| 51 | const call = (path: string, init: RequestInit = {}) => | |
| 52 | fetch(base + path, { redirect: "manual", ...init, headers: { ...headers, ...init.headers } }); | |
| 53 | ||
| 54 | let id = 0; | |
| 55 | test("the hostile listing launches and its assets are edited", async () => { | |
| 56 | const launched = await call("/listings", { method: "POST", body: form }); | |
| 57 | expect(launched.status).toBe(303); | |
| 58 | id = Number(/\/listings\/(\d+)/.exec(launched.headers.get("location")!)![1]); | |
| 59 | for (const kind of ["mls", "social", "flyer", "email"]) { | |
| 60 | const edit = new FormData(); | |
| 61 | edit.set("text", PAYLOADS.asset); | |
| 62 | expect((await call(`/listings/${id}/assets/${kind}`, { method: "POST", body: edit })).status).toBe(303); | |
| 63 | } | |
| 64 | }); | |
| 65 | ||
| 66 | const floor = (r: Response) => { | |
| 67 | expect(r.headers.get("content-security-policy")).toBeTruthy(); | |
| 68 | expect(r.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 69 | expect(r.headers.get("referrer-policy")).toBeTruthy(); | |
| 70 | }; | |
| 71 | ||
| 72 | const expectInert = (html: string) => { | |
| 73 | for (const raw of RAW) expect(html).not.toContain(raw); | |
| 74 | // Only the app's own external scripts; no inline or injected ones. | |
| 75 | for (const s of html.matchAll(/<script\b[^>]*>/g)) expect(s[0]).toMatch(/^<script[^>]*\ssrc="\/[^"/]/); | |
| 76 | // No attribute carries a live handler or a javascript: URL. | |
| 77 | for (const tag of html.matchAll(/<[a-z](?:"[^"]*"|'[^']*'|[^>"'])*>/gi)) { | |
| 78 | // Quoted values are data; what is left is names, which may not be handlers. | |
| 79 | expect(tag[0].replace(/"[^"]*"|'[^']*'/g, '""')).not.toMatch(/\son[a-z]+\s*=/i); | |
| 80 | expect(tag[0]).not.toMatch(/(?:href|src)\s*=\s*["']?\s*javascript:/i); | |
| 81 | } | |
| 82 | expect(html).not.toMatch(/<textarea[^>]*>(?:(?!<\/textarea>)[^])*<\/textarea>[^]*?<script>alert/); | |
| 83 | }; | |
| 84 | ||
| 85 | for (const path of ["/", "/listings/:id", "/listings/:id/flyer", "/listings/:id/email"]) { | |
| 86 | test(`${path} escapes every typed word`, async () => { | |
| 87 | const r = await call(path.replace(":id", String(id))); | |
| 88 | expect(r.status).toBe(200); | |
| 89 | floor(r); | |
| 90 | const html = await r.text(); | |
| 91 | expectInert(html); | |
| 92 | // The words are still there, as typed, escaped. | |
| 93 | if (path !== "/") expect(html).toContain(`"><img src=x onerror=alert(2)>`); | |
| 94 | }); | |
| 95 | } | |
| 96 | ||
| 97 | test("share and Open Graph tags escape the listing's words", async () => { | |
| 98 | for (const path of [`/listings/${id}`, `/listings/${id}/email`, `/listings/${id}/flyer`]) { | |
| 99 | const html = await (await call(path)).text(); | |
| 100 | const metas = [...html.matchAll(/<meta\b[^>]*>/g)].map((m) => m[0]); | |
| 101 | expect(metas.length).toBeGreaterThan(0); | |
| 102 | for (const m of metas) { | |
| 103 | expect(m).not.toMatch(/<[a-z]+[^>]*<|"\s*[a-z-]+=\s*"(?:alert|x)/i); | |
| 104 | expect((m.match(/"/g) ?? []).length % 2).toBe(0); | |
| 105 | } | |
| 106 | } | |
| 107 | }); | |
| 108 | ||
| 109 | test("the email's copy source is the escaped article", async () => { | |
| 110 | const html = await (await call(`/listings/${id}/email`)).text(); | |
| 111 | const article = /<article class="email[^]*?<\/article>/.exec(html)![0]; | |
| 112 | expectInert(article); | |
| 113 | expect(article).toContain("data-subject"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("JSON surfaces carry the words as data, typed as JSON and unsniffable", async () => { | |
| 117 | const api = await call(`/api/listings/${id}`); | |
| 118 | floor(api); | |
| 119 | expect(api.headers.get("content-type")).toContain("application/json"); | |
| 120 | const body = (await api.json()) as { address: string; assets: { text: string }[] }; | |
| 121 | expect(body.address).toBe(PAYLOADS.address.replace(/\s+/g, " ")); | |
| 122 | expect(body.assets.some((a) => a.text === PAYLOADS.asset.replace(/\r\n?/g, "\n").trim())).toBe(true); | |
| 123 | ||
| 124 | const mcp = await call("/mcp", { | |
| 125 | method: "POST", | |
| 126 | headers: { "content-type": "application/json" }, | |
| 127 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", | |
| 128 | params: { name: "get_listing", arguments: { id } } }), | |
| 129 | }); | |
| 130 | floor(mcp); | |
| 131 | expect(mcp.headers.get("content-type")).toContain("application/json"); | |
| 132 | expect(JSON.stringify(await mcp.json())).toContain("alert(1)"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the event stream is data, never a page", async () => { | |
| 136 | const r = await call("/events?since=0"); | |
| 137 | floor(r); | |
| 138 | expect(r.headers.get("content-type")).toContain("text/event-stream"); | |
| 139 | const reader = r.body!.getReader(); | |
| 140 | const seen = new TextDecoder().decode((await reader.read()).value); | |
| 141 | await reader.cancel(); | |
| 142 | // Every line is a field of the stream: a payload cannot start a new one. | |
| 143 | for (const l of seen.split("\n")) expect(l).toMatch(/^(?:|id: .*|event: .*|data: .*|retry: .*|:.*)$/); | |
| 144 | }); | |
| 145 | ||
| 146 | test("error pages and unknown paths keep the floor and escape what they echo", async () => { | |
| 147 | for (const path of ["/listings/99999", `/listings/%3Cscript%3Ealert(1)%3C%2Fscript%3E`, "/nope%22%3E%3Cscript%3E"]) { | |
| 148 | const r = await call(path); | |
| 149 | floor(r); | |
| 150 | expect(await r.text()).not.toContain("<script>alert"); | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 154 | test("the terminal draws hostile words as text: no control byte but its own styling", async () => { | |
| 155 | const { openDb } = await import("./db.ts"); | |
| 156 | const { insertListing } = await import("./actions.ts"); | |
| 157 | const { session } = await import("./tty.ts"); | |
| 158 | const { SAMPLE } = await import("./sample.ts"); | |
| 159 | const evil = "\x1b[2J\x1b]0;pwned\x07\x9b31m\x90x\x9c\x08\x7f"; | |
| 160 | const me = { role: "user", id: "tty-author", name: `me${evil}` } as const; | |
| 161 | const db = openDb(mkdtempSync(join(tmpdir(), "hostile-tty-"))); | |
| 162 | // Straight into the database: what is stored may predate the boundary's checks. | |
| 163 | const made = insertListing(db, me, { | |
| 164 | ...SAMPLE, | |
| 165 | address: `1 ${evil} Main`, | |
| 166 | lot: `lot${evil}`, | |
| 167 | features: SAMPLE.features.map((f) => f + evil), | |
| 168 | agent_name: `Ann${evil}`, | |
| 169 | brokerage: `Br${evil}`, | |
| 170 | license: `L${evil}`, | |
| 171 | agent_email: `a${evil}@e.co`, | |
| 172 | }, [], null); | |
| 173 | db.query("UPDATE asset SET body = ?1 WHERE listing_id = ?2").run(`Subject: s${evil}\n\nbody${evil}\n\nAnn${evil}`, made.id); | |
| 174 | ||
| 175 | // Our own styling is all `ESC [ digits m` (plus the clear and home that open a screen). | |
| 176 | const ours = /\x1b\[(?:\d+(?:;\d+)*m|2J|H)/g; | |
| 177 | const bare = (screen: string) => screen.replace(ours, ""); | |
| 178 | const forbidden = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/; | |
| 179 | const tty = session(db, me); | |
| 180 | expect(bare(tty.screen())).not.toMatch(forbidden); // the list | |
| 181 | expect(tty.screen()).toContain("1 "); | |
| 182 | tty.press("\r"); | |
| 183 | expect(bare(tty.screen())).not.toMatch(forbidden); // the listing | |
| 184 | expect(tty.screen()).toContain("body"); | |
| 185 | tty.press("p"); | |
| 186 | expect(bare(tty.screen())).not.toMatch(forbidden); // the email preview | |
| 187 | tty.press("\x1b"); | |
| 188 | tty.press("\x1b"); | |
| 189 | tty.press("\t"); | |
| 190 | expect(bare(tty.screen())).not.toMatch(forbidden); // the notes | |
| 191 | }); |
src/live.ts +2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | import { SECURITY } from "./http.ts"; | |
| 14 | 15 | |
| 15 | 16 | export interface LiveEvent { |
| 16 | 17 | readonly id: number; |
| @@ -137,6 +138,7 @@ export function events(db: Database, request: Request): Response { | ||
| 137 | 138 | }); |
| 138 | 139 | return new Response(stream, { |
| 139 | 140 | headers: { |
| 141 | ...SECURITY, | |
| 140 | 142 | "content-type": "text/event-stream", |
| 141 | 143 | "cache-control": "no-store", |
| 142 | 144 | "x-accel-buffering": "no", |
src/tty.ts +22 −4
| @@ -42,6 +42,21 @@ const bold = (s: string): string => `${esc}1m${s}${esc}0m`; | ||
| 42 | 42 | const amber = (s: string): string => `${esc}33m${s}${esc}0m`; |
| 43 | 43 | const green = (s: string): string => `${esc}32m${s}${esc}0m`; |
| 44 | 44 | |
| 45 | /** | |
| 46 | * Every string in what the database holds, with the control characters a | |
| 47 | * terminal would obey (ESC, CSI, OSC, C1) turned into spaces. Words reach the | |
| 48 | * screen through this, so only the styling drawn here can move the cursor. | |
| 49 | * Line breaks stay: assets are several lines. | |
| 50 | */ | |
| 51 | const scrub = <T>(value: T): T => { | |
| 52 | if (typeof value === "string") | |
| 53 | return value.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, " ") as T; | |
| 54 | if (Array.isArray(value)) return value.map(scrub) as T; | |
| 55 | if (value !== null && typeof value === "object") | |
| 56 | return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrub(v)])) as T; | |
| 57 | return value; | |
| 58 | }; | |
| 59 | ||
| 45 | 60 | /** In the one-line edit box, `\n` typed as two characters is a line break. */ |
| 46 | 61 | export const toLine = (text: string): string => text.replace(/\n/g, "\\n"); |
| 47 | 62 | export const fromLine = (line: string): string => line.replace(/\\n/g, "\n"); |
| @@ -59,13 +74,16 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 59 | 74 | |
| 60 | 75 | const notes = () => { |
| 61 | 76 | const listed = listNotes(db, me); |
| 62 | return listed.ok ? listed.value : []; | |
| 77 | return listed.ok ? scrub(listed.value) : []; | |
| 63 | 78 | }; |
| 64 | 79 | const listings = () => { |
| 65 | 80 | const listed = listListings(db, me); |
| 66 | return listed.ok ? listed.value : []; | |
| 81 | return listed.ok ? scrub(listed.value) : []; | |
| 82 | }; | |
| 83 | const open = () => { | |
| 84 | const found = reading === undefined ? undefined : getListing(db, me, { id: reading }); | |
| 85 | return found === undefined ? undefined : scrub(found); | |
| 67 | 86 | }; |
| 68 | const open = () => (reading === undefined ? undefined : getListing(db, me, { id: reading })); | |
| 69 | 87 | |
| 70 | 88 | function readLines(): string[] { |
| 71 | 89 | const found = open(); |
| @@ -99,7 +117,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 99 | 117 | |
| 100 | 118 | function screen(): string { |
| 101 | 119 | const lines = [ |
| 102 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, | |
| 120 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${scrub(me.name)}${me.role === "admin" ? " (admin)" : ""}`)}`, | |
| 103 | 121 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| 104 | 122 | "", |
| 105 | 123 | ]; |
new above
offline
Add a terminal surface here: BIDI is the only non-HTML control payload; include \x1b[2J and \x1b]0;x\x07 in the payloads and render through src/tty.ts.