Public: anyone can read this; the crew can change it.

An issue, pull, tell and repository description written as HTML show as text on every page and card that draws them #5911

Merged opened this

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

Merged

scout/forge:scout/5900-an-issue-pull-tell-and-repository-descri → main 2 commits fork

  • Merged. scout/5900-an-issue-pull-tell-and-repository-descri was merged into main as d6327a6.

Review

Sign in to review this change.
1 file changed +217 −0
test/xss-everywhere.test.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * One hostile string travels everywhere: as the title and body of an issue,
3 * a pull and a tell, and as a repository's description. Every page, feed and
4 * card that draws it shows it as text, never as markup.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { tell } from "../src/actions/tell.ts";
13 import { createApp as insertApp } from "../src/core/apps.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { gitClientEnv } from "./helpers/git-client.ts";
19 import { CARD_SHELL } from "../src/web/studio/cards.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import type { Screen } from "../src/tui/app/app.ts";
22 import { issueScreen } from "../src/tui/screens/issue.ts";
23 import { pullScreen } from "../src/tui/screens/pull.ts";
24 import { repoScreen } from "../src/tui/screens/repo.ts";
25 import { local } from "../src/tui/screens/where.ts";
26 import { headlessTerminal } from "../src/tui/term/terminal.ts";
27 import { settleApp } from "./helpers/tui.ts";
28 import { serve, type Servers } from "../src/http/server.ts";
29
30 const EVIL = `<img src=x onerror=alert(1)>"'`;
31 // What an escaped copy looks like, whichever of the escapers drew it.
32 const ESCAPED = "&lt;img src=x onerror=alert(1)&gt;";
33
34 describe("one hostile string, every surface", () => {
35 let dataDir: string;
36 let app: App;
37 let servers: Servers;
38 let token: string;
39 let issueNo: number;
40 let pullNo: number;
41 let id = 1;
42 let alice: User;
43 let game: Repo;
44
45 const get = async (path: string) => {
46 const res = await fetch(`${servers.url}${path}`, {
47 headers: { authorization: `Bearer ${token}` },
48 });
49 expect(res.status).toBe(200);
50 return res.text();
51 };
52 const mcp = async (name: string, args: unknown) => {
53 const res = await fetch(`${servers.url}/mcp`, {
54 method: "POST",
55 headers: {
56 "content-type": "application/json",
57 accept: "application/json, text/event-stream",
58 authorization: `Bearer ${token}`,
59 },
60 body: JSON.stringify({
61 jsonrpc: "2.0",
62 id: id++,
63 method: "tools/call",
64 params: { name, arguments: args },
65 }),
66 });
67 return res.text();
68 };
69
70 beforeAll(async () => {
71 dataDir = await mkdtemp(join(tmpdir(), "forge-xss-all-"));
72 app = createApp({ dataDir, port: 0 });
73 servers = await serve(app);
74 const a = await createUser(app.db, {
75 origin: "seed",
76 trusted: true,
77 username: "alice",
78 email: "[email protected]",
79 password: "a-good-passphrase",
80 adminIfFirst: true,
81 });
82 if (!a.ok) throw new Error(a.error);
83 alice = a.value;
84 token = createToken(app.db, a.value.id, "rw", "rw").token;
85 const repo = await createRepo(app, a.value, {
86 name: "game",
87 visibility: "public",
88 description: EVIL,
89 });
90 if (!repo.ok) throw new Error(String(repo.error));
91 game = repo.value;
92 // Explore lists apps, so the repository is one.
93 const rec = insertApp(app.db, { repoId: repo.value.id, slug: "alice-game", access: "public" });
94 if (!rec.ok) throw new Error(rec.error);
95 const issue = createIssue(app.db, {
96 repoId: repo.value.id,
97 authorId: a.value.id,
98 title: EVIL,
99 body: EVIL,
100 });
101 if (!issue.ok) throw new Error(issue.error);
102 issueNo = issue.value.number;
103 // A real pull needs two real branches.
104 const work = await mkdtemp(join(tmpdir(), "forge-xss-all-work-"));
105 const env = gitClientEnv();
106 const git = async (...args: string[]) => {
107 const proc = Bun.spawn(["git", ...args], { cwd: work, env, stdout: "pipe", stderr: "pipe" });
108 const [err, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
109 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
110 };
111 const remote = `${servers.url.replace("http://", `http://alice:${token}@`)}/alice/game.git`;
112 await git("init", "-b", "main");
113 await Bun.write(join(work, "a.txt"), "a\n");
114 await git("add", ".");
115 await git("commit", "-m", "genesis");
116 await git("push", remote, "main");
117 await git("checkout", "-b", "topic");
118 await Bun.write(join(work, "b.txt"), "b\n");
119 await git("add", ".");
120 await git("commit", "-m", "topic");
121 await git("push", remote, "topic");
122 await rm(work, { recursive: true, force: true });
123 const made = await fetch(`${servers.url}/api/v1/repos/alice/game/pulls`, {
124 method: "POST",
125 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
126 body: JSON.stringify({ title: EVIL, body: EVIL, base: "main", head: "topic" }),
127 });
128 if (made.status !== 201) throw new Error(`pull: ${made.status} ${await made.text()}`);
129 pullNo = ((await made.json()) as { number: number }).number;
130 const told = await tell(app, a.value, repo.value, EVIL);
131 if (!told.ok) throw new Error(String(told.error));
132 }, 30_000);
133
134 afterAll(async () => {
135 servers.stop();
136 app.close();
137 await rm(dataDir, { recursive: true, force: true });
138 });
139
140 const pages: [string, () => string][] = [
141 ["the home feed", () => "/"],
142 ["the issue", () => `/alice/game/issues/${issueNo}`],
143 ["the pull", () => `/alice/game/pulls/${pullNo}`],
144 ["the issue list", () => "/alice/game/issues"],
145 ["search", () => "/search?q=img"],
146 ["explore", () => "/explore"],
147 ["asked", () => "/asked"],
148 ["the repository home", () => "/alice/game"],
149 ["the activity feed", () => "/alice/game/activity.atom"],
150 ["the waves feed", () => "/alice/game/waves.atom"],
151 ];
152 for (const [label, path] of pages)
153 test(`${label} draws it as text`, async () => {
154 const html = await get(path());
155 expect(html).not.toContain("<img");
156 expect(html).not.toContain(`onerror=alert(1)>"`);
157 // Not vacuous: the page drew the string, as text.
158 expect(html).toContain(ESCAPED);
159 });
160
161 test("the tell is drawn as text beside the issue on the issue list", async () => {
162 const list = await get("/alice/game/issues");
163 // The issue and the tell are two rows, each with the escaped title.
164 expect(list.split(ESCAPED).length - 1).toBeGreaterThanOrEqual(2);
165 });
166
167 test("the terminal draws the issue, the pull and the repository without control bytes", async () => {
168 const terminal = headlessTerminal(160, 50);
169 const { app: tui, session } = createForgeApp(
170 app,
171 { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice },
172 { terminal, exitHooks: false },
173 );
174 tui.start();
175 try {
176 await settleApp(tui);
177 const screens: [Screen, string | undefined][] = [
178 [issueScreen(session, tui.ctx, local(session, game), issueNo), undefined],
179 [pullScreen(session, tui.ctx, local(session, game), pullNo), undefined],
180 // The repository's description is on its About card, under `I`.
181 [repoScreen(session, tui.ctx, local(session, game)), "I"],
182 ];
183 for (const [screen, press] of screens) {
184 tui.push(screen);
185 await settleApp(tui);
186 if (press !== undefined) {
187 tui.dispatch({ type: "key", key: press, ctrl: false, alt: false, shift: false });
188 await settleApp(tui);
189 }
190 const text = tui.screen.toText();
191 // A terminal has no markup: the words are shown as they were said.
192 expect(text).toContain("<img src=x onerror=alert(1)>");
193 expect(text).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f]/);
194 if (press !== undefined) tui.pop();
195 tui.pop();
196 await settleApp(tui);
197 }
198 } finally {
199 tui.stop();
200 }
201 });
202
203 test("MCP feed and issue cards carry it as data the shell draws with textContent", async () => {
204 // `feed` has a card; `issue` answers with data alone, so it has none.
205 const feed = JSON.parse(await mcp("feed", {})).result;
206 const card = feed.structuredContent?.card;
207 expect(card).toBeDefined();
208 expect(card.rows.map((r: { label: string }) => r.label).join("\n")).toContain(EVIL);
209 expect(JSON.stringify(card)).not.toContain("&lt;");
210 const issue = JSON.parse(await mcp("issue", { repo: "alice/game", number: issueNo })).result;
211 expect(issue.structuredContent?.card).toBeUndefined();
212 expect(JSON.parse(issue.content[0].text).title).toBe(EVIL);
213 const shell = CARD_SHELL("feed");
214 expect(shell).not.toContain("innerHTML");
215 expect(shell).toContain("textContent");
216 });
217 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs