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

A child forge's look of its own app ends on the browser's error page while the app answers 200 from outside #11651

Merged opened this

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

Merged

mason/forge:mason/11519-a-child-forge-s-look-of-its-own-app-ends → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/11519-a-child-forge-s-look-of-its-own-app-ends was merged into main as e1bbdcb.

Review

Sign in to review this change.
5 files changed +135 −4
src/apps/proxy.ts +19 −1
@@ -262,6 +262,17 @@ export function signGrant(
262262 return `${payload}.${mac(dataDir, payload)}`;
263263 }
264264
265 /**
266 * The forge's own proof that a grant link is for the browser it opens a look
267 * with, on the forge's plain-http listener (`looks/service.ts`). Such a
268 * browser drops a `Secure` cookie set over http, so the grant it follows
269 * sets a plain one; only a link carrying this proof (which only this forge
270 * can mint, and only for that token) gets it. Everyone else keeps `Secure`.
271 */
272 export function plainProof(dataDir: string, token: string): string {
273 return mac(dataDir, `plain.${token}`);
274 }
275
265276 function mac(dataDir: string, payload: string): string {
266277 return createHmac("sha256", loadKey(dataDir))
267278 .update(payload)
@@ -1754,7 +1765,14 @@ function grant(
17541765 vouched.who,
17551766 );
17561767 const path = safePath(url.searchParams.get("to"));
1757 const secure = proxySecure(request, url, runtime.options.forgeUrl);
1768 const proof = url.searchParams.get("plain") ?? "";
1769 const expected = Buffer.from(plainProof(runtime.app.config.dataDir, token));
1770 const given = Buffer.from(proof);
1771 const plain =
1772 url.protocol === "http:" &&
1773 given.length === expected.length &&
1774 timingSafeEqual(given, expected);
1775 const secure = !plain && proxySecure(request, url, runtime.options.forgeUrl);
17581776 return new Response(null, {
17591777 status: 303,
17601778 headers: {
src/looks/capture.ts +1 −1
@@ -224,7 +224,7 @@ export function refusal(
224224 href: string,
225225 ): string | undefined {
226226 if (navigationError !== undefined && navigationError !== "")
227 return `the browser showed an error page instead of the app (${navigationError.slice(0, 100)})`;
227 return `${navigationError.slice(0, 100)}: the browser showed an error page instead of the app`;

The message order changed but no test asserts it. Add one for the net:: code leading the message and for the 100-char cap.

228228 let url: URL;
229229 try {
230230 url = new URL(href);
src/looks/service.ts +18 −2
@@ -31,7 +31,7 @@ import {
3131 import { dirname, join } from "node:path";
3232 import type { App } from "../app.ts";
3333 import { envBuild, envPull, isEnvName, previewEnv, type EnvName } from "../apps/data.ts";
34 import { signGrant, type GrantWho } from "../apps/proxy.ts";
34 import { plainProof, signGrant, type GrantWho } from "../apps/proxy.ts";
3535 import { appsRuntime } from "../apps/runtime.ts";
3636 import {
3737 getAppById,
@@ -218,6 +218,22 @@ interface Pending {
218218 readonly swept?: true;
219219 }
220220
221 /**
222 * What the browser opens: the app's grant door on the forge's own plain-http
223 * listener. The `plain` proof tells the door this is that browser, which
224 * would drop a `Secure` cookie on http, so a forge whose public address is
225 * https still hands it a cookie it keeps (#11519).
226 */
227 export function lookUrl(
228 dataDir: string,
229 host: string,
230 port: string,
231 token: string,
232 page: string,
233 ): string {
234 return `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&plain=${plainProof(dataDir, token)}&to=${encodeURIComponent(page)}`;
235 }
236
221237 export function startLooks(app: App, options: LooksOptions): LooksHandle {
222238 const { dataDir } = app.config;
223239 const port = new URL(options.localUrl).port;
@@ -465,7 +481,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
465481 Date.now(),
466482 asNobody(kind, page) ? undefined : isNewcomerLook(page) ? newcomer : asOwner,
467483 );
468 const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`;
484 const url = lookUrl(dataDir, host, port, token, page);
469485 const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme);
470486 const out = join(dataDir, path);
471487 // Taken to one side and moved in whole. A pull that closes while its
test/look-refusal.test.ts +7
@@ -25,4 +25,11 @@ describe("refusal", () => {
2525 test("a page with no address is refused", () => {
2626 expect(refusal(HOST, undefined, "")).toBeDefined();
2727 });
28
29 test("the browser's net:: code leads the message, capped at 100 characters", () => {
30 const href = "chrome-error://chromewebdata/";
31 expect(refusal(HOST, "net::ERR_X", href)).toStartWith("net::ERR_X: the browser showed an error page");
32 const why = refusal(HOST, "net::" + "E".repeat(200), href) ?? "";
33 expect(why).toStartWith("net::" + "E".repeat(95) + ": the browser showed");
34 });
2835 });
test/look-secure-grant.test.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * A child forge's look of its own app (#11519). A forge whose public address
3 * is https marks the grant cookie `Secure`; the look's browser opens the app
4 * on the forge's plain-http listener and, like Chrome, keeps no `Secure`
5 * cookie set over http. It then arrived without its grant and was sent to
6 * sign in on the public host, which a cell cannot reach: an error page. The
7 * look's grant link carries the forge's proof, and the cookie it sets stays.
8 */
9 import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp } from "../src/app.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { signGrant } from "../src/apps/proxy.ts";
16 import { newApp } from "../src/actions/apps.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve } from "../src/http/server.ts";
19 import { lookUrl } from "../src/looks/service.ts";
20 import { refusal } from "../src/looks/capture.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24 setDefaultTimeout(60_000);
25
26 const dirs: string[] = [];
27 const stops: (() => Promise<void> | void)[] = [];
28 afterAll(async () => {
29 for (const stop of stops) await stop();
30 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
31 });
32
33 const HOST = "cookiegrant.child.test";
34
35 /** Where a browser that keeps no `Secure` cookie from http ends up, given the look's URL. */
36 async function browse(url: string, port: string): Promise<{ finalHost: string; status: number; location: string | null }> {
37 let jar = "";
38 let at = new URL(url);
39 for (let hop = 0; hop < 5; hop++) {
40 if (at.hostname !== HOST) return { finalHost: at.hostname, status: 0, location: null };
41 const res = await fetch(`http://127.0.0.1:${port}${at.pathname}${at.search}`, {
42 redirect: "manual",
43 headers: { host: HOST, accept: "text/html", ...(jar === "" ? {} : { cookie: jar }) },
44 });
45 for (const line of res.headers.getSetCookie()) {
46 if (/;\s*secure\b/i.test(line)) continue; // http: the browser drops it
47 jar = line.split(";")[0] ?? "";
48 }
49 const location = res.headers.get("location");
50 if (res.status < 300 || res.status >= 400 || location === null)
51 return { finalHost: at.hostname, status: res.status, location };
52 at = new URL(location, at);
53 }
54 return { finalHost: at.hostname, status: -1, location: null };
55 }
56
57 describe("a look of a private app on a forge whose public address is https", () => {
58 test("the grant door keeps the browser on the app's host, and the picture is not refused", async () => {
59 const dir = await mkdtemp(join(tmpdir(), "forge-look-secure-"));
60 dirs.push(dir);
61 const child = createApp({ dataDir: dir, port: 0, url: "https://child.test" });
62 const cs = await serve(child);
63 stops.push(() => cs.stop());
64 const made_ = await createUser(child.db, { origin: "seed", trusted: true, username: "olive", email: "[email protected]", password: "a-long-family-passphrase", adminIfFirst: true });
65 if (!made_.ok) throw new Error(made_.error);
66 const runtime = new AppsRuntime(child, { domain: "child.test", scheme: "https", publicPort: 443, forgeUrl: "https://child.test", concurrency: 1 });
67 await runtime.start();
68 stops.push(() => runtime.stop());
69 const made = await newApp(child, made_.value, { name: "cookiegrant", visibility: "private" });
70 if (!made.ok) throw new Error(made.error);
71 const target = { app: made.value.app, env: "production" as const };
72 const token = signGrant(child.config.dataDir, target, 60_000, Date.now(), { id: made_.value.id, name: "olive", role: "admin" });
73
74 // The look's own link: the cookie is kept, and the page is the app's.
75 const url = lookUrl(child.config.dataDir, HOST, String(cs.port), token, "/");
76 const got = await browse(url, String(cs.port));
77 expect(got.finalHost).toBe(HOST);
78 expect(refusal(HOST, undefined, `http://${HOST}/`)).toBeUndefined();
79
80 // Without the proof (a link anyone could make) the cookie stays Secure,
81 // and a browser that drops it is sent to sign in on the public host.
82 const bare = `http://${HOST}:${cs.port}/__forge/grant?t=${encodeURIComponent(token)}&to=/`;
83 const lost = await browse(bare, String(cs.port));
84 expect(lost.finalHost).toBe("child.test");
85
86 // A forged proof is no proof.
87 const forged = `${url.replace(/plain=[^&]+/, "plain=forged")}`;
88 expect((await browse(forged, String(cs.port))).finalHost).toBe("child.test");
89 });
90 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs