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

Merged opened this

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

Merged

mason/listing-launch:mason/15-a-hostile-listing-cannot-break-any-page → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/15-a-hostile-listing-cannot-break-any-page was merged into main as eda813e.

Review

Sign in to review this change.
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";

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.

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'"&amp;${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(`&quot;&gt;&lt;img src=x onerror=alert(2)&gt;`);
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 @@
1111 */
1212
1313 import type { Database } from "bun:sqlite";
14 import { SECURITY } from "./http.ts";
1415
1516 export interface LiveEvent {
1617 readonly id: number;
@@ -137,6 +138,7 @@ export function events(db: Database, request: Request): Response {
137138 });
138139 return new Response(stream, {
139140 headers: {
141 ...SECURITY,
140142 "content-type": "text/event-stream",
141143 "cache-control": "no-store",
142144 "x-accel-buffering": "no",
src/tty.ts +22 −4
@@ -42,6 +42,21 @@ const bold = (s: string): string => `${esc}1m${s}${esc}0m`;
4242 const amber = (s: string): string => `${esc}33m${s}${esc}0m`;
4343 const green = (s: string): string => `${esc}32m${s}${esc}0m`;
4444
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
4560 /** In the one-line edit box, `\n` typed as two characters is a line break. */
4661 export const toLine = (text: string): string => text.replace(/\n/g, "\\n");
4762 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 }
5974
6075 const notes = () => {
6176 const listed = listNotes(db, me);
62 return listed.ok ? listed.value : [];
77 return listed.ok ? scrub(listed.value) : [];
6378 };
6479 const listings = () => {
6580 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);
6786 };
68 const open = () => (reading === undefined ? undefined : getListing(db, me, { id: reading }));
6987
7088 function readLines(): string[] {
7189 const found = open();
@@ -99,7 +117,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
99117
100118 function screen(): string {
101119 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)" : ""}`)}`,
103121 ssh === undefined ? "" : dim(`connect: ${ssh}`),
104122 "",
105123 ];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs