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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5900-an-issue-pull-tell-and-repository-descri → main 2 commits fork
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 = "<img src=x onerror=alert(1)>"; | |
| 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("<"); | |
| 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 | }); |
new above
offline