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) <noreply@anthropic.com>
This commit is contained in:
dckcandClaude Opus 5.5 committed 2026-10-13 21:44:29 -05:00
1 parent 28b1c3c9d3
commit 193a126d1c
5 files changed
+28 -24

No files matched your search

+3 -3
View File
@@ -19,9 +19,9 @@ Every start states the vat's grants, as JSON labels in query parameters; absent
## Ingress ## 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 ## Egress
@@ -40,7 +40,7 @@ The switchboard has two sockets, so two root handles: `public` (no loopback or p
```sh ```sh
pnpm install 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 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 pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state
``` ```
+2 -2
View File
@@ -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. * the vat has. A mount with no labels matches any request.
* *
* Stricter than Flower's match (no conflict): an ingress client holds no * 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 * constrained handle that supplies labels for it, so a request reaches a
* label would protect nothing. * vat only by naming every label of its mount.
*/ */
export const LOOKUP = ` export const LOOKUP = `
SELECT m.vat, m.labels FROM mount m JOIN ( SELECT m.vat, m.labels FROM mount m JOIN (
+10 -9
View File
@@ -320,21 +320,22 @@ export class Directory extends DurableObject {
} }
/** /**
* A request's labels: its query parameters, plus a `Toybox-Labels` header * A request's labels: its query parameters.
* (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 * Routing carries no secrets: a URL ends up in logs and `Referer`s. A
* it in the header. * 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 * @param {Request} request
*/ */
const requestLabels = (request) => { const requestLabels = (request) => {
const query = {}; const labels = {};
for (const [k, v] of new URL(request.url).searchParams) { for (const [k, v] of new URL(request.url).searchParams) {
if (k in query) throw new Error(`label ${k} given twice`); if (k in labels) throw new Error(`label ${k} given twice`);
query[k] = v; labels[k] = v;
} }
const header = request.headers.get("Toybox-Labels"); return Object.freeze(labels);
return mergeLabels(query, header ? JSON.parse(header) : {});
}; };
/** /**
+3 -3
View File
@@ -19,11 +19,11 @@ const makeDb = () => {
test("a request reaches the mount whose every label it carries", () => { test("a request reaches the mount whose every label it carries", () => {
const { mount, lookup } = makeDb(); const { mount, lookup } = makeDb();
mount("a", { vat: "alice", key: "s3cret" }); mount("a", { vat: "alice", flavor: "plain" });
mount("b", { vat: "bob" }); 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" }), 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({ vat: "bob" }), "b");
assert.equal(lookup({}), null); assert.equal(lookup({}), null);
}); });
+10 -7
View File
@@ -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}$/); 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 () => { test("a request reaches a vat only by naming every label of its mount", async () => {
const secret = "k".repeat(43); await admin.create(await app("show-labels.js"), { mount: { vat: "both", flavor: "plain" } });
await admin.create(await app("show-labels.js"), { mount: { vat: "safe", key: secret } }); assert.equal((await pub.get("/?vat=both")).status, 404);
assert.equal((await pub.get("/?vat=safe")).status, 404); assert.equal((await pub.get("/?vat=both&flavor=fancy")).status, 404);
assert.equal((await pub.get("/?vat=safe&key=wrong")).status, 404); assert.equal((await pub.get("/?vat=both&flavor=plain")).status, 200);
const got = await pub.get("/?vat=safe", { "Toybox-Labels": JSON.stringify({ key: secret }) }); });
assert.equal(got.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 () => { test("code, state, and mount survive a restart", async () => {