Public: anyone can read this; the crew can change it.
An app host's /__forge/ door escapes every name it draws and answers a private app's stranger with the same 404 as an unknown app #5915
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5908-an-app-host-s-forge-door-escapes-every-n → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +46
−10
src/apps/proxy.ts +15 −6
| @@ -40,7 +40,7 @@ import { feedback, mintProof } from "./feedback.ts"; | ||
| 40 | 40 | import { clientBucket } from "../http/peer.ts"; |
| 41 | 41 | import { doorPage } from "./door.ts"; |
| 42 | 42 | import { takePhases, timePhase } from "../core/route_timing.ts"; |
| 43 | import { notFound } from "../http/respond.ts"; | |
| 43 | import { notFound, securityFloor } from "../http/respond.ts"; | |
| 44 | 44 | import { appMeasure } from "./measure.ts"; |
| 45 | 45 | import { holdPortRequest, holdPortSocket } from "./handoff.ts"; |
| 46 | 46 | import { htmlAnswer, readPage, rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| @@ -628,17 +628,26 @@ export async function proxyApp( | ||
| 628 | 628 | // is not shown. A nested host is its guest's, and has no such page. |
| 629 | 629 | if (url.pathname === "/__forge/") { |
| 630 | 630 | if (target.nested) return notFound(); |
| 631 | if (request.method !== "GET" && request.method !== "HEAD") | |
| 632 | return new Response(null, { | |
| 633 | status: 405, | |
| 634 | headers: { allow: "GET, HEAD", "x-content-type-options": "nosniff" }, | |
| 635 | }); | |
| 636 | 631 | const signed = timePhase(request, "session", () => { |
| 637 | 632 | const cookie = cookieValue(request, grantCookieName(target)); |
| 638 | 633 | return cookie === undefined |
| 639 | 634 | ? undefined |
| 640 | 635 | : readGrant(runtime.app.config.dataDir, cookie, target); |
| 641 | 636 | }); |
| 637 | // A private app (or a sealed pull's preview) is not here to anyone it | |
| 638 | // does not admit: the answer is the one a name nobody holds gets, byte | |
| 639 | // for byte, before the method is even looked at. | |
| 640 | if ( | |
| 641 | (target.app.access === "private" || | |
| 642 | sealedPull(runtime, target) !== undefined) && | |
| 643 | (signed === undefined || !stillEntitled(runtime, target, signed)) | |
| 644 | ) | |
| 645 | return securityFloor(new Response("not found", { status: 404 })); | |
| 646 | if (request.method !== "GET" && request.method !== "HEAD") | |
| 647 | return new Response(null, { | |
| 648 | status: 405, | |
| 649 | headers: { allow: "GET, HEAD", "x-content-type-options": "nosniff" }, | |
| 650 | }); | |
| 642 | 651 | const tell = timePhase(request, "app", () => |
| 643 | 652 | tellLink( |
| 644 | 653 | runtime.app.db, |
test/apps-door.test.ts +31 −4
| @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { newApp } from "../src/actions/apps.ts"; |
| 9 | import { doorPage } from "../src/apps/door.ts"; | |
| 10 | import type { PillWhere } from "../src/apps/toolbar.ts"; | |
| 9 | 11 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; |
| 10 | 12 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 11 | 13 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| @@ -136,11 +138,21 @@ test("a private repository is linked only to who may read it", async () => { | ||
| 136 | 138 | `${cookie}=${signGrant(dataDir, target, 60_000, Date.now(), { id, name, role })}`; |
| 137 | 139 | const host = `vault.${DOMAIN}`; |
| 138 | 140 | |
| 141 | // Not here to a stranger: the very answer a name nobody holds gets. | |
| 142 | const unknown = await door(`nothing.${DOMAIN}`); | |
| 143 | const unknownBody = await unknown.text(); | |
| 144 | expect(unknown.status).toBe(404); | |
| 139 | 145 | for (const cookies of [undefined, as(b.value.id, "bob", "user")]) { |
| 140 | const html = await (await door(host, cookies)).text(); | |
| 141 | expect(html).toContain("<h1>vault</h1>"); | |
| 142 | expect(html).not.toContain("alice/vault"); | |
| 143 | expect(html).not.toContain("The repository"); | |
| 146 | const res = await door(host, cookies); | |
| 147 | const body = await res.text(); | |
| 148 | expect(res.status).toBe(unknown.status); | |
| 149 | expect(body).toBe(unknownBody); | |
| 150 | expect(body).not.toContain("vault"); | |
| 151 | expect([...res.headers].filter(([k]) => k !== "date")).toEqual( | |
| 152 | [...unknown.headers].filter(([k]) => k !== "date"), | |
| 153 | ); | |
| 154 | const post = await fetch(`${servers.url}/__forge/`, { method: "POST", headers: { host } }); | |
| 155 | expect(post.status).toBe(404); | |
| 144 | 156 | } |
| 145 | 157 | const mine = await (await door(host, as(alice, "alice", "admin"))).text(); |
| 146 | 158 | expect(mine).toContain(`href="${servers.url}/alice/vault"`); |
| @@ -166,6 +178,21 @@ test("HEAD answers the headers with no body; POST is a 405 that says what is all | ||
| 166 | 178 | expect(post.headers.get("x-content-type-options")).toBe("nosniff"); |
| 167 | 179 | }); |
| 168 | 180 | |
| 181 | test("every name the door draws is escaped, for production and a preview", () => { | |
| 182 | const evil = `<script>alert("x")</script>'`; | |
| 183 | const out = (where: PillWhere) => | |
| 184 | doorPage({ where, forgeUrl: "http://forge.test", signin: "/s" }, false).text(); | |
| 185 | return Promise.all([ | |
| 186 | out({ app: evil, env: "production", repo: evil }), | |
| 187 | out({ app: evil, env: `pull-${evil}`, repo: evil, pull: evil as unknown as number }), | |
| 188 | ]).then((pages) => { | |
| 189 | for (const html of pages) { | |
| 190 | expect(html).not.toContain("<script>"); | |
| 191 | expect(html).toContain("<script>alert("x")</script>'"); | |
| 192 | } | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 169 | 196 | const phasesOf = async (host: string, path: string, cookie?: string) => { |
| 170 | 197 | let header = ""; |
| 171 | 198 | for (let i = 0; i < 2; i++) { |
new above
offline
The private/sealed gate is now written here, at line 718 and in the note door. Next time, pull it into one helper (guarded + stillEntitled) so the doors cannot drift.