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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11519-a-child-forge-s-look-of-its-own-app-ends → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +135
−4
src/apps/proxy.ts +19 −1
| @@ -262,6 +262,17 @@ export function signGrant( | ||
| 262 | 262 | return `${payload}.${mac(dataDir, payload)}`; |
| 263 | 263 | } |
| 264 | 264 | |
| 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 | ||
| 265 | 276 | function mac(dataDir: string, payload: string): string { |
| 266 | 277 | return createHmac("sha256", loadKey(dataDir)) |
| 267 | 278 | .update(payload) |
| @@ -1754,7 +1765,14 @@ function grant( | ||
| 1754 | 1765 | vouched.who, |
| 1755 | 1766 | ); |
| 1756 | 1767 | 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); | |
| 1758 | 1776 | return new Response(null, { |
| 1759 | 1777 | status: 303, |
| 1760 | 1778 | headers: { |
src/looks/capture.ts +1 −1
| @@ -224,7 +224,7 @@ export function refusal( | ||
| 224 | 224 | href: string, |
| 225 | 225 | ): string | undefined { |
| 226 | 226 | 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`; | |
| 228 | 228 | let url: URL; |
| 229 | 229 | try { |
| 230 | 230 | url = new URL(href); |
src/looks/service.ts +18 −2
| @@ -31,7 +31,7 @@ import { | ||
| 31 | 31 | import { dirname, join } from "node:path"; |
| 32 | 32 | import type { App } from "../app.ts"; |
| 33 | 33 | 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"; | |
| 35 | 35 | import { appsRuntime } from "../apps/runtime.ts"; |
| 36 | 36 | import { |
| 37 | 37 | getAppById, |
| @@ -218,6 +218,22 @@ interface Pending { | ||
| 218 | 218 | readonly swept?: true; |
| 219 | 219 | } |
| 220 | 220 | |
| 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 | ||
| 221 | 237 | export function startLooks(app: App, options: LooksOptions): LooksHandle { |
| 222 | 238 | const { dataDir } = app.config; |
| 223 | 239 | const port = new URL(options.localUrl).port; |
| @@ -465,7 +481,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 465 | 481 | Date.now(), |
| 466 | 482 | asNobody(kind, page) ? undefined : isNewcomerLook(page) ? newcomer : asOwner, |
| 467 | 483 | ); |
| 468 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`; | |
| 484 | const url = lookUrl(dataDir, host, port, token, page); | |
| 469 | 485 | const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme); |
| 470 | 486 | const out = join(dataDir, path); |
| 471 | 487 | // 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", () => { | ||
| 25 | 25 | test("a page with no address is refused", () => { |
| 26 | 26 | expect(refusal(HOST, undefined, "")).toBeDefined(); |
| 27 | 27 | }); |
| 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 | }); | |
| 28 | 35 | }); |
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 | }); |
new above
offline
The message order changed but no test asserts it. Add one for the net:: code leading the message and for the 100-char cap.