From 193a126d1c06465bc25dcf2c42ad9b096fbf8f9b Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 13 Oct 2026 21:12:38 -0500 Subject: [PATCH] capsite/toybox: routing carries no secrets The ingress no longer reads labels from a Toybox-Labels header. A header was no place for the webkey pattern: browsers can't set one on a WebSocket, and the pattern puts the secret in-band anyway. A page keeps it in its fragment, reaches the vat by public labels, and sends it in the body of a call, which the vat checks like a swissnum. A request still reaches a vat only by naming every label of its mount. Co-Authored-By: Claude Opus 5.5 (1M context) --- toybox/README.md | 6 +++--- toybox/mounts-sql.js | 4 ++-- toybox/supervisor.js | 19 ++++++++++--------- toybox/test/mounts-sql.test.js | 6 +++--- toybox/test/vat.test.js | 17 ++++++++++------- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/toybox/README.md b/toybox/README.md index 3e1ab5f..0210256 100644 --- a/toybox/README.md +++ b/toybox/README.md @@ -19,9 +19,9 @@ Every start states the vat's grants, as JSON labels in query parameters; absent ## Ingress -A request's labels are its query parameters, plus a `Toybox-Labels` header (JSON) for secret ones. A URL ends up in logs and `Referer`s, so a secret label belongs in the header: a webkey page keeps the secret in its fragment and sends it in-band. A key given twice is a 400. +A request's labels are its query parameters; a key given twice is a 400. Routing carries no secrets: a URL ends up in logs and `Referer`s. A secret travels in-band, as on a webkey page: the page keeps it in its fragment, reaches the vat by public labels, and sends it in the body of a call, which the vat checks like a swissnum. -The request reaches the vat whose mount labels it carries, every one with the same value. That is stricter than Flower's match (no conflicting key): an ingress client holds no constrained handle, so a label it omits must not match, or a secret label would protect nothing. The path is the app's own. The app sees the mount's and the request's labels together in `Toybox-Connection-Labels` (JSON), as a Flower server gets its connection labels. Responses carry `Referrer-Policy: no-referrer`. +The request reaches the vat whose mount labels it carries, every one with the same value. That is stricter than Flower's match (no conflicting key): an ingress client holds no constrained handle that supplies labels for it, so it reaches a vat only by naming every label of its mount. The path is the app's own. The app sees the mount's and the request's labels together in `Toybox-Connection-Labels` (JSON), as a Flower server gets its connection labels. Responses carry `Referrer-Policy: no-referrer`. ## Egress @@ -40,7 +40,7 @@ The switchboard has two sockets, so two root handles: `public` (no loopback or p ```sh pnpm install -pnpm test # real workerds: createVat, upgrade, mounts, secret labels, egress grants and constrain, fetch and WebSocket out, crash and restart +pnpm test # real workerds: createVat, upgrade, mounts, egress grants and constrain, fetch and WebSocket out, crash and restart pnpm switchboard # systemd-socket-activate on ./switchboard-public.sock and ./switchboard-local.sock pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state ``` diff --git a/toybox/mounts-sql.js b/toybox/mounts-sql.js index e86de2a..db9e725 100644 --- a/toybox/mounts-sql.js +++ b/toybox/mounts-sql.js @@ -37,8 +37,8 @@ export const UNMOUNT_LABELS = "DELETE FROM mount_label WHERE vat = ?1"; * the vat has. A mount with no labels matches any request. * * Stricter than Flower's match (no conflict): an ingress client holds no - * constrained handle, so a label it omits must not match, or a secret - * label would protect nothing. + * constrained handle that supplies labels for it, so a request reaches a + * vat only by naming every label of its mount. */ export const LOOKUP = ` SELECT m.vat, m.labels FROM mount m JOIN ( diff --git a/toybox/supervisor.js b/toybox/supervisor.js index 17eaa38..f63cc5b 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -320,21 +320,22 @@ export class Directory extends DurableObject { } /** - * A request's labels: its query parameters, plus a `Toybox-Labels` header - * (JSON) for labels too secret for a URL, which ends up in logs and - * `Referer`s. A webkey page keeps such a secret in its fragment and sends - * it in the header. + * A request's labels: its query parameters. + * + * Routing carries no secrets: a URL ends up in logs and `Referer`s. A + * secret travels in-band instead, as on a webkey page: the page keeps it + * in its fragment, reaches the vat by public labels, and sends it in the + * body of a call, which the vat checks like a swissnum. * * @param {Request} request */ const requestLabels = (request) => { - const query = {}; + const labels = {}; for (const [k, v] of new URL(request.url).searchParams) { - if (k in query) throw new Error(`label ${k} given twice`); - query[k] = v; + if (k in labels) throw new Error(`label ${k} given twice`); + labels[k] = v; } - const header = request.headers.get("Toybox-Labels"); - return mergeLabels(query, header ? JSON.parse(header) : {}); + return Object.freeze(labels); }; /** diff --git a/toybox/test/mounts-sql.test.js b/toybox/test/mounts-sql.test.js index 0376e80..3e6787e 100644 --- a/toybox/test/mounts-sql.test.js +++ b/toybox/test/mounts-sql.test.js @@ -19,11 +19,11 @@ const makeDb = () => { test("a request reaches the mount whose every label it carries", () => { const { mount, lookup } = makeDb(); - mount("a", { vat: "alice", key: "s3cret" }); + mount("a", { vat: "alice", flavor: "plain" }); mount("b", { vat: "bob" }); - assert.equal(lookup({ vat: "alice", key: "s3cret", page: "2" }), "a"); + assert.equal(lookup({ vat: "alice", flavor: "plain", page: "2" }), "a"); assert.equal(lookup({ vat: "alice" }), null); - assert.equal(lookup({ vat: "alice", key: "wrong" }), null); + assert.equal(lookup({ vat: "alice", flavor: "fancy" }), null); assert.equal(lookup({ vat: "bob" }), "b"); assert.equal(lookup({}), null); }); diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js index d50c71b..0f8fdfe 100644 --- a/toybox/test/vat.test.js +++ b/toybox/test/vat.test.js @@ -171,13 +171,16 @@ test("mounts that could both match a request are refused", async () => { assert.match((await admin.create(src, { mount: { vat: "y", n: "2" } })).vat, /^[0-9a-f]{64}$/); }); -test("a secret label: in a header, not the URL; omitting it doesn't match", async () => { - const secret = "k".repeat(43); - await admin.create(await app("show-labels.js"), { mount: { vat: "safe", key: secret } }); - assert.equal((await pub.get("/?vat=safe")).status, 404); - assert.equal((await pub.get("/?vat=safe&key=wrong")).status, 404); - const got = await pub.get("/?vat=safe", { "Toybox-Labels": JSON.stringify({ key: secret }) }); - assert.equal(got.status, 200); +test("a request reaches a vat only by naming every label of its mount", async () => { + await admin.create(await app("show-labels.js"), { mount: { vat: "both", flavor: "plain" } }); + assert.equal((await pub.get("/?vat=both")).status, 404); + assert.equal((await pub.get("/?vat=both&flavor=fancy")).status, 404); + assert.equal((await pub.get("/?vat=both&flavor=plain")).status, 200); +}); + +test("routing reads no labels from headers", async () => { + const got = await pub.get("/?vat=both", { "Toybox-Labels": JSON.stringify({ flavor: "plain" }) }); + assert.equal(got.status, 404); }); test("code, state, and mount survive a restart", async () => {