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

Merged opened this

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

Merged

scout/forge:scout/5908-an-app-host-s-forge-door-escapes-every-n → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5908-an-app-host-s-forge-door-escapes-every-n was merged into main as 9e5250c.

Review

Sign in to review this change.
2 files changed +46 −10
src/apps/proxy.ts +15 −6
@@ -40,7 +40,7 @@ import { feedback, mintProof } from "./feedback.ts";
4040 import { clientBucket } from "../http/peer.ts";
4141 import { doorPage } from "./door.ts";
4242 import { takePhases, timePhase } from "../core/route_timing.ts";
43 import { notFound } from "../http/respond.ts";
43 import { notFound, securityFloor } from "../http/respond.ts";
4444 import { appMeasure } from "./measure.ts";
4545 import { holdPortRequest, holdPortSocket } from "./handoff.ts";
4646 import { htmlAnswer, readPage, rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
@@ -628,17 +628,26 @@ export async function proxyApp(
628628 // is not shown. A nested host is its guest's, and has no such page.
629629 if (url.pathname === "/__forge/") {
630630 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 });
636631 const signed = timePhase(request, "session", () => {
637632 const cookie = cookieValue(request, grantCookieName(target));
638633 return cookie === undefined
639634 ? undefined
640635 : readGrant(runtime.app.config.dataDir, cookie, target);
641636 });
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 (

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.

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 });
642651 const tell = timePhase(request, "app", () =>
643652 tellLink(
644653 runtime.app.db,
test/apps-door.test.ts +31 −4
@@ -6,6 +6,8 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { newApp } from "../src/actions/apps.ts";
9 import { doorPage } from "../src/apps/door.ts";
10 import type { PillWhere } from "../src/apps/toolbar.ts";
911 import { signGrant, type AppTarget } from "../src/apps/proxy.ts";
1012 import { AppsRuntime } from "../src/apps/runtime.ts";
1113 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 () => {
136138 `${cookie}=${signGrant(dataDir, target, 60_000, Date.now(), { id, name, role })}`;
137139 const host = `vault.${DOMAIN}`;
138140
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);
139145 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);
144156 }
145157 const mine = await (await door(host, as(alice, "alice", "admin"))).text();
146158 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
166178 expect(post.headers.get("x-content-type-options")).toBe("nosniff");
167179 });
168180
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("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&#39;");
192 }
193 });
194 });
195
169196 const phasesOf = async (host: string, path: string, cookie?: string) => {
170197 let header = "";
171198 for (let i = 0; i < 2; i++) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs