From 2f64657a67fa6a44da2d797d6552ae109aca6c13 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:19:55 -0700 Subject: [PATCH 1/4] Align Hosted and one-time references with the current runtime --- docs/specs/hosted.md | 26 +++++--- docs/specs/one-time.md | 108 +++++++++++++++++----------------- docs/specs/security-hosted.md | 18 +++--- hosted/README.md | 15 ++--- 4 files changed, 92 insertions(+), 75 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 39c46374a..06af8528f 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -13,9 +13,9 @@ | `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | -Every Worker answers `/api/health`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. The account falls back to its SPA assets, the relay to Pocket's. The 421 origin gate, each Worker's bindings mapper, and the cookie routes' exact-`Origin` check are `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). +Every Worker answers `/api/health`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. The account falls back to its SPA assets, the relay to Pocket's. Origin and binding isolation: `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). -Marketing is a separate bundle and deployment. `remote-lib-common` is compiled in from source through `hosted/tsconfig.json` `paths`, which every esbuild bundle and Wrangler honor. +Marketing deploys separately; esbuild and Wrangler compile `remote-lib-common` from source through `hosted/tsconfig.json` `paths`. **Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding. The runtime creates and closes its database pool within each request. @@ -45,11 +45,11 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings` **Must link the account footer to the public Hosted privacy policy and terms.** -**Must show configured sign-in methods only.** Email has send, existing-code, verify, resend, and change-address paths. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. +**Must show configured sign-in methods only.** Email supports sign-in and address-change actions. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. **Must check the account on return to the page and serialize submitted actions.** Authenticated data remains in memory; login tokens never enter local storage. Only public identity fields are rendered, without provider images or external assets. The Voice tokens and Computers sections render only when `GET /api/voice/tokens` and `GET /api/relay/burrows` succeed, and neither failure fails the account page; a minted token stays in memory and is shown once. -**Must inherit Dormouse product theme tokens before mounting React.** The OS light/dark preference selects bundled Light Visual Studio or Kimbie Dark. Its type scale and touch sizing are in `hosted/src/style.css`. It loads no marketing styles, fonts, or analytics. +**Must inherit Dormouse product theme tokens before mounting React.** The OS light/dark preference selects bundled Light Visual Studio or Kimbie Dark. Type and touch sizing: `hosted/src/style.css`. It loads no marketing styles, fonts, or analytics. Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/main.tsx`; `hosted/src/style.css`. @@ -119,6 +119,9 @@ A session-gated route answers a session of an account no longer entitled with th - **Must answer 429 with `Retry-After` past the per-address limit on `signin/*` (`RELAY_SIGNIN_LIMIT`) and `setup/begin`/`finish` (`RELAY_SETUP_LIMIT`)**, before the body limit and any database read: 30 a minute, a ceremony's two routes sharing one budget (rationale). - **Must restore a token a refused `finish` spent on its original expiry, within the Burrow's cap, and never once that expiry has passed.** +Known gap: restored-token admission samples time before locking, permitting +expired reinsertion. + **Push.** The push routes keep `docs/specs/relay.md` -> "Web Push" and its "State files" upsert rules; a send is HTTPS from the Burrow to the relay Worker, independent of terminal transport. - **Must keep subscriptions in Postgres** (`hosted/server/dormouse-migrations/003_relay_push.sql`), keyed `(burrowId, deliveryId)`, every field bounded as self-host bounds it, deleted with their Burrow. **Must read the addresses a delivery moves off, drop rows, and prune 404/410 among the account's rows only.** @@ -148,7 +151,10 @@ The relay Worker serves `GET /ws/burrow` and `GET /ws/client` at the self-host p - **Must measure a text frame in UTF-8 bytes against `MAX_RELAY_FRAME_BYTES` before parsing it**, as the self-host `maxPayload` counts, closing 1009 past it; a binary frame is dropped. The Client cap is per account object. - **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes each removed or another account's 4001 (`WS_CLOSE_BURROW_REVOKED`) and each de-entitled 4002 (`WS_CLOSE_BURROW_NOT_ENTITLED`), its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps. - **Must answer `RELAY_PING` with `RELAY_PONG` as the object's auto-response**, which never wakes it, reaches no handler, and is never forwarded. **A socket whose last auto-response is older than `RELAY_IDLE_TIMEOUT_MS` is gone**, retired with 1001; a socket that never pinged is never judged. It replaces the self-host heartbeat. Two gaps are accepted: a Burrow that never pings is never judged, since every build that can enroll with Hosted pings; and at the Client cap a backgrounded Pocket, its pings paused, counts as gone once silent that long — the cap is far above real use, and the Burrow reaps its session at 120 s anyway. -- **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. No route reaches either RPC; `GET /api/burrows` reads `online` through `onlineBurrows`, beside its query. +- **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. `GET /api/burrows` reads `online` through account-scoped `onlineBurrows`. **Never expose either RPC or `RelayRows` as a public endpoint.** + +Known gap: sweep-read failures violate the revocation bound; see +`docs/specs/security-hosted.md` -> "Relay boundary". Source of truth: `relaySocketRoutes` in `hosted/server/relay-sockets.ts`; `relayRoom` / `RELAY_ROOM_PARAMS` / `RELAY_ROOM_SWEEP_MS` / `RELAY_ROW_READ_TIMEOUT_MS` in `hosted/server/relay-room-contract.ts`; `RelayRoom` in `hosted/server/relay-room.ts`; `RelayRows` in `hosted/server/relay-rows.ts`; `forwardUpgrade` / `refuseSocket` in `hosted/server/socket-room.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `exceedsRelayFrameBytes` in `remote-lib-common/src/remote/relay-routing.ts`; `durable_objects` and `migrations` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`. Pinned by `hosted/server/tests/relay-room.test.ts`, which also runs the routing cases every Relay passes (`remote-lib-common/test/harness/relay-parity.mjs`), and `hosted/server/tests/workers.test.ts`. @@ -179,7 +185,11 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that 403 f - **Must count every approval attempt** against `RELAY_APPROVE_LIMIT` (10 a minute per account) before reading the body: 429 with `Retry-After` past it. - **Must answer a malformed code 400**, forgiving case, spaces, and dashes, and a second approval of a live code 409 `ALREADY_APPROVED`, whichever account sends it: a live approval never moves. An expired one is replaced. - **Must remove by deleting the Burrow's row**, its setup tokens and setup challenges cascading, so `burrowByToken` finds nothing on any relay route, **then close its live socket** ("Relay sockets"). -- **Must take the `/enroll` fragment before render and erase it from history**, as `/connect/` does, and hold the code in memory only: through email sign-in and back, never into storage. A fragment change on `/enroll` takes the new code the same way without reloading; elsewhere it does nothing. Provider sign-in leaves the page, so the user opens the link again. +- **Must take the `/enroll` fragment before render and erase it from history**, as `/connect/` does, and hold the code in memory only: through email sign-in and back, never into storage. A fragment change on `/enroll` takes the new code without reloading; elsewhere it does nothing. Provider sign-in leaves the page, so the user opens the link again. + +Known gaps: approval buffers bodies before auth/rate gates; poll expiry and +entitlement checks precede the account lock; racing last-slot polls can answer +capacity before redeemed status. Stale approval completion can clear a newer link. The page shows the code in the user-code role with "Approve only if Dormouse on your computer is showing this code right now." and offers Approve only within the recent-login window, Sign in again otherwise. The account page's Computers section lists each Burrow by its ID's first eight characters and enrollment date (the Relay keeps no name), with Remove. @@ -189,7 +199,7 @@ Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccount **Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and the voice token and Relay account routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback without a database, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free suites (the rendezvous, Pocket's serving, the three Workers' boundary, and push egress); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free suites (the rendezvous, Pocket's serving, the three Workers' boundary, and push egress); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.test.ts` needing Docker. Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. @@ -211,7 +221,7 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf **Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair) — names only, as Cloudflare exposes no value. Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. -**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key (`pushConfigSmoke`: both VAPID secrets set, as one pair) and runs `oneTimeSmoke` on it, whatever the account's outcome; a failed smoke reports every failed part. +**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. Migration inventories: `migrations` in the account and relay Wrangler configs. A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key (`pushConfigSmoke`: both VAPID secrets set, as one pair) and runs `oneTimeSmoke` on it, whatever the account's outcome; a failed smoke reports every failed part. **Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. Production repeats only the relay and voice smokes, up to six times 10 s apart (rationale). diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index acc0f8835..31b33667b 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -1,13 +1,7 @@ # One-time connection -> See `docs/specs/glossary.md` for Burrow, Client, Relay, Pane, and Baseboard vocabulary; this spec uses them bare. -> Owns the one-time connection: the link a Burrow shows, the Hosted rendezvous that carries only its handshake, and the direct-only session that follows. Defers the ceremony's trust rules to `docs/specs/remote-security-model.md` -> "One-time connection" and the audited checks to `docs/specs/security-remote.md` -> "One-time connection" and `docs/specs/security-hosted.md` -> "Rendezvous boundary". - -A phone reaches a laptop with no account, no Relay, and no passkey: the laptop -shows a link, the phone opens it, a person types on the laptop the two digits the -phone shows, and the session runs over a direct WebRTC path the network policy -allows (`docs/specs/remote-network.md`). -Hosted's rendezvous carries the handshake and nothing after it. +> See `docs/specs/glossary.md` for Burrow, Client, Relay, Pane, and Baseboard vocabulary. +> Owns one-time links, Hosted rendezvous, and direct-only sessions. Trust rules: `docs/specs/remote-security-model.md` -> "One-time connection"; audited checks: `docs/specs/security-remote.md` -> "One-time connection" and `docs/specs/security-hosted.md` -> "Rendezvous boundary". ## Flow @@ -20,8 +14,8 @@ Hosted's rendezvous carries the handshake and nothing after it. ([Laptop UI](#laptop-ui)). 4. The phone page takes and erases the fragment, then waits for the Connect tap ([Phone page](#phone-page)). -5. The tap runs `connectOnce` ([Phone client](#phone-client)), and its first - message 1 reserves the link ([Burrow runtime](#burrow-runtime)). +5. The tap runs `connectOnce` ([Phone client](#phone-client)), which completes the + handshake ([Burrow runtime](#burrow-runtime)). 6. The page shows the two digits; the laptop's approval modal takes the one attempt. 7. A match promotes the session, and the page shows "Connecting directly…" @@ -57,13 +51,10 @@ reaches a server. `docs/specs/relay.md` -> "Setup tokens and the pairing QR" states, with the path exactly `ONE_TIME_PAGE_PATH` (`/connect/`) and the fragment right after `#`; then the field rules, the expiry, and the X25519 import last. -- **A link is live through its expiry second** (`oneTimeLinkExpired`), and the +- **Must keep a link live through `expiry * 1000`, expiring the following millisecond** (`oneTimeLinkExpired`), and the parser refuses on that same rule. A caller that must tell an expired link from a wrong one parses at `now = 0` and asks `oneTimeLinkExpired`. -- The prologue is `lengthPrefixedConcat` of `dormouse/e2e/v1`, `one-time`, - `roomId`, then `v`, `expiry`, `ephPub` in link order, from one builder both - ends call (the rule: `docs/specs/remote-security-model.md` -> "One-time - connection"). +- The prologue contract: `docs/specs/remote-security-model.md` -> "One-time connection". Which desktop release may carry a link version: `docs/specs/deploy.md` -> "Release checklist". @@ -80,13 +71,13 @@ Source of truth: `formatOneTimeLinkUrl` / `parseOneTimeLinkUrl` / `BurrowRuntime` has a reader for it. Two WebSocket routes on the one-time origin: `ONE_TIME_WS_ROUTES.burrow` (`/api/one-time/burrow`) mints a room, and `ONE_TIME_WS_ROUTES.client` (`/api/one-time/client?room=`) joins one. -Each message is one JSON frame with exact keys, forwarded verbatim by the room: +**Must send one exact-key JSON frame per message, forwarded verbatim by the room.** Canonical types and guards own its fields: -| Frame | Direction | Shape | +| Frame | Direction | Contract | | --- | --- | --- | -| `OneTimeRoomFrame` | room → Burrow, once, first | `{t: 'one-time-room', roomId, expiresAt}`, `expiresAt` epoch ms whose whole seconds fit a uint32 | -| `OneTimeClientFrame` | phone → Burrow | `{t: 'one-time', step: 'init' \| 'transport', ct}` | -| `OneTimeBurrowFrame` | Burrow → phone | `{t: 'one-time', step: 'response' \| 'transport', ct}` | +| `OneTimeRoomFrame` | room → Burrow, once, first | room identity and expiry in epoch ms whose whole seconds fit a uint32 | +| `OneTimeClientFrame` | phone → Burrow | Noise message 1, then transport | +| `OneTimeBurrowFrame` | Burrow → phone | Noise message 2, then transport | `ct` is one base64url Noise message, bounded as on the relay envelope. Each end keeps its open socket alive with the relay socket's heartbeat @@ -102,12 +93,11 @@ no deadline. 5 minutes). The join and the confirmation finish by its expiry, and the direct path's `DIRECT_ONLY_DEADLINE_MS` (30 s) after the outcome ends inside the room's hard deadline, `expiresAt + ONE_TIME_EXPIRY_GRACE_MS` (45 s). -- **The ceremony's messages are padded `control` messages** on the Noise session - (`docs/specs/relay.md` -> "E2E framing"): `OneTimeRequestV1 {code, label}` - phone → Burrow, `label` one of `ONE_TIME_DEVICE_LABELS`, then one - `OneTimeOutcomeV1` — `{ok: true, burrowLabel}`, or - `{ok: false, code}` with `code` one of `ONE_TIME_DENIAL_CODES` - (`user-denied`, `confirmation-mismatch`, `link-expired`, `burrow-error`). +- **Must send one padded `control` request phone → Burrow, then one outcome Burrow → phone** + on the Noise session (`docs/specs/relay.md` -> "E2E framing"). + `OneTimeRequestV1` carries the confirmation digits and a `ONE_TIME_DEVICE_LABELS` + label; `OneTimeOutcomeV1` carries the approved Burrow label or a + `ONE_TIME_DENIAL_CODES` refusal. Their guards own exact shapes and values. The room closes a socket with one of six codes, each exported with a `_REASON`: @@ -120,7 +110,8 @@ The room closes a socket with one of six codes, each exported with a `_REASON`: | 4014 | `WS_CLOSE_ONE_TIME_DEADLINE` | a phone joined, and the hard deadline passed | | 4015 | `WS_CLOSE_ONE_TIME_VIOLATION` | a binary frame, one over the length bound, or one past the message cap | -Source of truth: `remote-lib-common/src/remote/one-time-wire.ts`; +Source of truth: `OneTimeRoomFrame` / `OneTimeClientFrame` / `OneTimeBurrowFrame` +and their guards in `remote-lib-common/src/remote/one-time-wire.ts`; `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts`; `OneTimeRequestV1` / `OneTimeOutcomeV1` in `remote-lib-common/src/security/e2e-ceremony.ts`. Pinned by @@ -136,17 +127,18 @@ resumes. | `OneTimeState` | Meaning | | --- | --- | | `opening` | minting the keypair, then waiting up to `ONE_TIME_OPEN_TIMEOUT_MS` (8 s) for the room frame | -| `waiting {url, expiresAt}` | the link is live; `expiresAt` is its last live millisecond | -| `confirming {label, expiresAt}` | a phone's request awaits the approval modal | -| `connecting {label}` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | -| `connected {label, since}` | both directions are direct and the rendezvous is closed | -| `ended {reason}` | terminal | +| `waiting` | the link is live; its published expiry is its last live millisecond | +| `confirming` | a phone's request awaits the approval modal | +| `connecting` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | +| `connected` | both directions are direct and the rendezvous is closed | +| `ended` | terminal | `unavailable {reason}` and `idle` complete the type for the service, which decides them; a runtime never enters either. - **Must open the socket through the host's factory with no `Origin` header**, on the origin's Burrow route with `http` replaced by `ws`. + - **The first message must be one `OneTimeRoomFrame`**, else `unreachable`. The link's expiry is the earlier of the runtime's own `now + ONE_TIME_LINK_TTL_MS` and the room's `expiresAt`, floored to whole seconds. @@ -155,6 +147,8 @@ decides them; a runtime never enters either. stops reading the room. Frames run through one FIFO, one at a time, and **the socket's close rides the same FIFO**, so a phone's `direct-switch` is read before the room's report that the phone left. +- **Must reserve the link and erase its key only after message 1, message 2, and + Noise Split succeed; a failed handshake leaves it live.** - **Must spend an `E2E_INIT_BURST` `TokenBucket` token before an `init`'s WebCrypto.** The first non-keepalive transport message must be `OneTimeRequestV1`, else `burrow-error`. **Its label passes @@ -192,6 +186,9 @@ decides them; a runtime never enters either. | `rendezvous-lost` | any other close before the switch | | `burrow-error` | room close `4015`, a protocol violation, an application message over the rendezvous, a room past the message cap, or a local failure | +Known gap: the open deadline and End settle state, but `open()` still awaits +pending key generation before returning. Its phase guard prevents a late socket. + Source of truth: `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`; `directOnly`, `onDirectOnlyBroken`, and `directDeadlineAt` in @@ -217,6 +214,7 @@ at most one session**, on `ClientSessionCore`, direct or not at all. - **Never open a socket outside `connectOnce`**, which runs once per client and opens none for an expired link. + - **Never import store, passkey, push, or worker code** (the static's rule: `docs/specs/remote-security-model.md` -> "One-time connection"). - Every protocol-v1 method refuses until both directions are direct (Direct @@ -227,6 +225,10 @@ at most one session**, on `ClientSessionCore`, direct or not at all. - After the switch (the same carve-out), channel loss, the laptop's goodbye, or the idle deadline reaches `setOnEnded` once; `close()` reports nothing. +Known gap: WebCrypto or an unopened socket can wait without a timer. A delayed +approved outcome also starts a fresh direct wait rather than using the room's +remaining lifetime. These paths do not meet the deadline contract above. + Every failure resolves `{ok: false, message}` with fixed copy: | Failure | Copy | @@ -236,7 +238,7 @@ Every failure resolves `{ok: false, message}` with fixed copy: | an expired link, room close `4010` or `4014`, any other close before an outcome past the link's expiry, or no answer by the room's deadline | `ONE_TIME_LINK_EXPIRED_MESSAGE` | | between an `ok` outcome and the switch: a decline, a lost session, the deadline, or any other close | `ONE_TIME_DIRECT_FAILED_MESSAGE` | | between an `ok` outcome and the switch: the laptop's goodbye | `networkNotAllowedMessage` where it names the phone's address, `ONE_TIME_DIRECT_FAILED_MESSAGE` where it names the path and no address, else `ONE_TIME_ENDED_MESSAGE` | -| a socket that never opened | `ONE_TIME_UNREACHABLE_MESSAGE` | +| a socket refused or lost before opening | `ONE_TIME_UNREACHABLE_MESSAGE` | | any other close, `close()`, or a session lost between the switch and the resolve | `ONE_TIME_ENDED_MESSAGE` | | a payload on message 2, or an outcome its guard refuses | `ONE_TIME_DENIAL_MESSAGES['burrow-error']` | @@ -322,7 +324,7 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): push, or cookie, though Pocket keeps its own on the same origin. `applyPocketTheme` applies Pocket's default theme without reading or writing a stored pick, for the page and its `PocketWall`. `scripts/e2e-lint.mjs` holds the page to the client's store rule. -- **Mounts the wall only on `ok`**, through `mountRemoteWall`. End, Cancel, a +- **Must mount the wall only on `ok`**, through `mountRemoteWall`. End, Cancel, a reported ending, a failed mount, or a failed attachment closes the client and releases the adapter; the first ending's copy stays. - **The label the page sends is `oneTimeDeviceLabel`, never Pocket's @@ -343,18 +345,15 @@ the root, and checks the copy. **Serving.** The relay Worker answers `/connect`, `/connect/`, and `/connect/assets/*` from its assets, with no SPA fallback; an HTML answer under `/connect/assets/` and any other path under `/connect/` is a 404. -A hashed file there is cached immutably. Everything under `/connect` carries -this policy, from the relay's `APP_ORIGIN` (``; `` with `http` -replaced by `ws`); every other relay response carries Pocket's policy or -`RUNS_NOTHING_POLICY` (`docs/specs/security-hosted.md` -> "Relay boundary"): - -``` -default-src 'none'; script-src /connect/assets/ 'wasm-unsafe-eval'; -style-src /connect/assets/ 'unsafe-inline'; img-src /connect/ data: blob:; -font-src /connect/; media-src blob:; connect-src /api/one-time/client; -worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'; -object-src 'none'; sandbox allow-scripts allow-same-origin -``` +A hashed file there is cached immutably. Other relay responses follow +`docs/specs/security-hosted.md` -> "Relay boundary". + +- **Must confine page resources to the relay origin's `/connect/`, scripts and + external styles to `/connect/assets/`, and connections to its rendezvous client + route.** Inline styles and WebAssembly compilation are allowed; inline scripts + are not. Images may use data/blob URLs and media only blob URLs. +- **Must default-deny other sources and prohibit workers, objects, framing, + forms, base URLs, and popups.** The policy builder owns directive syntax. - **An `APP_ORIGIN` that is not exactly `scheme://host[:port]` of plain host characters gets `RUNS_NOTHING_POLICY` instead**: the URL parser admits `;`, @@ -362,6 +361,9 @@ object-src 'none'; sandbox allow-scripts allow-same-origin - **The sandbox keeps `allow-same-origin`**, and Chrome's warning about the pair stands (rationale). +Known gap: teardown can precede a pending wall completion, leaving a late +adapter attached after cleanup. + Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `lib/src/remote/one-time-app/OneTimeApp.tsx`; `takeOneTimeLinkUrl` / `reloadOnNewLink` in @@ -369,7 +371,7 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `lib/src/remote/pocket-app/pocket-theme.ts`; `assertPocketShell` in `lib/scripts/assert-pocket-worker.mjs`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `oneTimePageRoutes` in -`hosted/server/one-time.ts`; `relayRules` in +`hosted/server/one-time.ts`; `relayRules` / `oneTimePagePolicy` in `hosted/server/headers.ts`. Pinned by `lib/src/remote/one-time-app/OneTimeApp.test.tsx`, `lib/src/remote/pocket-app/assert-pocket-worker.test.ts`, @@ -462,13 +464,13 @@ sits in the Baseboard's right cluster (`docs/specs/layout.md` -> "Baseboard"). | State | The panel shows | Actions | | --- | --- | --- | -| `idle` | the **One-time connection** button; "Open a link on your phone for a one-off connection. Your phone must be on an allowed network. No account needed." | the button opens | +| `idle` | the **One-time connection** button; one-off, allowed-network, no-account hint | the button opens | | `unavailable` | the button disabled, the reason's copy for the hint | — | -| `opening` | "Getting a link…" | Cancel | -| `waiting` | the link as a QR code and as selectable text; "Good for one phone. Expires in N min." | Copy link, New link, Cancel | -| `confirming` | "Type the two digits your phone shows into the dialog." | Cancel | -| `connecting` | "Connecting directly…" | Cancel | -| `connected` | the phone's label, then "has full control of your terminals." | End | +| `opening` | progress | Cancel | +| `waiting` | QR and selectable link; single-phone hint and expiry in minutes | Copy link, New link, Cancel | +| `confirming` | instruction to type the phone's two digits in the dialog | Cancel | +| `connecting` | direct-connection progress | Cancel | +| `connected` | phone label and full-terminal-control notice | End | | `ended` | the reason's sentence, a refusal's from `pathRefusalSentence` | New link, Done | - **The panel renders the service's state and owns only its busy and error**; a diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 2186a578a..f51099623 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -2,12 +2,12 @@ > See `docs/specs/glossary.md` for Burrow, Client, and Relay vocabulary. > Owns the security checks of Hosted's three Workers, account, relay, and voice. Defers identity behavior and the Worker split to `docs/specs/hosted.md` and terminal access to `docs/specs/remote-security-model.md`. -> Read `docs/specs/security.md` first; provisioning and real-provider acceptance are pending. +> Read `docs/specs/security.md` first; live production controls and real-provider acceptance still require verification. ## Origin boundary - **FAIL IF** a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. -- **FAIL IF** a cookie route admits any `Origin` but its own exactly, sibling origins under `dormouse.sh` included — they are same-site, so the browser sends them the `SameSite=Lax` login cookie — or a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieAdmin` in `hosted/server/account-gate.ts` and the packed adapter. +- **FAIL IF** a cookie route admits any presented `Origin` but its own exactly, sibling origins under `dormouse.sh` included, or a state-changing cookie request lacks that Origin, a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieAdmin` in `hosted/server/account-gate.ts` and the packed adapter. - **FAIL IF** the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. - **FAIL IF** the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`. @@ -15,6 +15,9 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/one-time.test.ts`. +Known gap: `cookieAdmin` skips its Origin check on GET/HEAD, so a presented +foreign Origin on an admin read does not meet the cookie-route rule above. + ## Account boundary - **FAIL IF** the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. @@ -62,15 +65,16 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or stores anything but its account id; `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. - **FAIL IF** the client socket upgrade admits an `Origin` other than exactly the relay's `APP_ORIGIN`, the Burrow upgrade admits any `Origin`, either reaches the object before its token resolves to a live session or Burrow of an entitled account, or the Worker hands the object any header, token, or parameter of the caller's. -- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`, so a stalled database resets every socket of the account; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; or a route reaches `closeBurrow`, `onlineBurrows`, `RelayRows`, or any other `RelayRoom` method but the two upgrades. +- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; `RelayRoom.fetch` exposes non-upgrade routes, or public routes expose `RelayRows`, `closeBurrow`, or `onlineBurrows` RPC. Authenticated account-scoped internal calls for removal/online status remain required. - **FAIL IF** a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. +Known gap: a failed or timed-out row sweep retains authenticated forwarding +until a later sweep, contrary to the next-sweep revocation obligation above. + Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`. ## Deployment boundary -**Must depend on a released pgstencil** whose installed `dist/provenance.json` names a commit on pgstencil `main` with a passing `security-audit`. The core and auth packages must name the same clean commit. - - **FAIL IF** a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, `hosted/server/voice-worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. - **FAIL IF** either installed pgstencil package lacks `dist/provenance.json`, records `dirty`, or names a different commit; `pnpm-lock.yaml` resolves either package from outside npm; or a runtime import depends on a sibling pgstencil checkout. Inspect `verifyPackages` in `hosted/scripts/production.mjs`, `hosted/server/tests/artifacts.test.ts`, and Hosted runtime imports. - **FAIL IF** either installed package lacks a verified npm SLSA provenance attestation whose Fulcio certificate SAN names `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, whose source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) equals `dist/provenance.json`'s commit, or whose signed subject/payload disagrees with the installed package, certificate, or commit. @@ -84,6 +88,6 @@ Pinned by `hosted/server/tests/artifacts.test.ts`, `hosted/server/tests/workers. ## Future -**Production activation**, not checked until Hosted is provisioned: the live Hyperdrive and role values that `preflight` reads, and Cloudflare script injection excluded for the Hosted hostname (`hosted/README.md`). Checked-in placeholders prove none of them. +**Live production acceptance**: verify Hyperdrive and role values that `preflight` reads, and Cloudflare script injection excluded for the Hosted hostname (`hosted/README.md`). Recorded configuration proves neither live controls nor browser acceptance. -Public hosted voice and Relay need their own abuse, authorization, data-disclosure, and recovery checks first, and paid use an independent review of the remote model; `docs/specs/hosted.md` owns the staged work. +Public voice and Relay need abuse, authorization, data-disclosure, and recovery checks, and paid use independent remote-model review; `docs/specs/hosted.md` owns the staged work. diff --git a/hosted/README.md b/hosted/README.md index d5c927b3a..80e925cc5 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -7,8 +7,9 @@ connection's rendezvous and `/connect/` phone page at `https://relay.dormouse.sh` (`dormouse-relay`; [the one-time spec](../docs/specs/one-time.md)), and managed-voice speech at `https://voice.dormouse.sh` (`dormouse-voice`). The marketing website is a separate application. Managed voice and the Relay -admit only the admin account; the Relay enrolls no Burrow and carries no -terminal traffic yet. See +admit only the admin account; device-code Burrow enrollment and account-scoped +encrypted terminal routing are implemented. Real-provider and live production +acceptance remain separate release gates. See [the spec](../docs/specs/hosted.md), whose "Application boundary" owns what each Worker serves. @@ -82,8 +83,8 @@ branch. | Boundary | Resources | | --- | --- | | Preview | Dedicated test Cloudflare account with a registered workers.dev subdomain; dedicated empty Neon project and parent branch; GitHub `hosted-preview` environment | -| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` Durable Object namespace), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | -| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive shared by all three Workers; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` Durable Object namespace and rate-limit namespaces `1`–`6`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep Cron Trigger), each on its custom domain; GitHub `hosted-production` environment | +| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` and `RelayRoom` Durable Object namespaces), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | +| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive shared by all three Workers; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` and `RelayRoom` Durable Object namespaces and rate-limit namespaces `1`–`6`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep Cron Trigger), each on its custom domain; GitHub `hosted-production` environment | | Email | Dedicated Postmark server, verified `signin@dormouse.sh`, SPF/DKIM/DMARC, Apple Private Email Relay registration | | OAuth | Separate Dormouse GitHub, Google, Microsoft, and Apple registrations; exact callbacks below | | Release history | `hosted-release-tag` GitHub environment, an admin identity's repository-scoped Contents-write fine-grained PAT, immutable annotated `hosted/` tags | @@ -196,9 +197,9 @@ accounts. 2. Create a Cloudflare Hyperdrive configuration for that database with **query caching disabled**, using the runtime role, and keep its connection host and database identical to the direct migration URL. Enter connection credentials - directly in Cloudflare; replace the zero Hyperdrive ID in `wrangler.jsonc`, - `wrangler.relay.jsonc`, and `wrangler.voice.jsonc` with the resulting public ID for local operator - deployment. CI overrides both with the `HYPERDRIVE_ID` variable. + directly in Cloudflare; set the resulting public Hyperdrive ID in `wrangler.jsonc`, + `wrangler.relay.jsonc`, and `wrangler.voice.jsonc` for local operator + deployment. CI overrides all three with the `HYPERDRIVE_ID` variable. 3. Create the runtime role with SQL (`CREATE ROLE ... LOGIN PASSWORD ...`), not Neon’s Console/API role creation, which grants `neon_superuser`. Neon requires the password over the encrypted connection and rejects a From 435c26bc1276731ff92a36936a2a5a20b9cfc0aa Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:34:27 -0700 Subject: [PATCH 2/4] Retain the catch-all Hosted RPC boundary and qualify Origin evidence --- docs/specs/hosted.rationale.md | 2 +- docs/specs/security-hosted.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index c2ce005e2..7fd93fa1e 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -17,7 +17,7 @@ History sweep (sources checked 2026-09-22): Three origins (decided 2026-09-30): - Pocket (staged for the relay origin's root) and the `/connect/` page render untrusted terminal output. Script running on the account's origin could make any request the login cookie authorizes and read the answer, so `/connect/` moved to `relay.dormouse.sh` and the account kept its origin. -- Sibling origins under `dormouse.sh` are same-site, not same-origin: `SameSite=Lax` does not stop a browser from attaching the account's cookie to a request a `relay.` or `voice.` page makes to `hosted.`. The exact-`Origin` check on every cookie route is what refuses those requests. +- Sibling origins under `dormouse.sh` are same-site, not same-origin: `SameSite=Lax` does not stop a browser from attaching the account's cookie to a request a `relay.` or `voice.` page makes to `hosted.`. The exact-`Origin` check refuses state-changing cookie requests; admin reads retain the gap recorded in the spec. - No released desktop build bakes `hosted.dormouse.sh` (v1.1.0, the last release, predates one-time and managed voice), so the routes moved off it with no compatibility shim. ## Production releases diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index f51099623..672e74372 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -65,7 +65,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or stores anything but its account id; `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. - **FAIL IF** the client socket upgrade admits an `Origin` other than exactly the relay's `APP_ORIGIN`, the Burrow upgrade admits any `Origin`, either reaches the object before its token resolves to a live session or Burrow of an entitled account, or the Worker hands the object any header, token, or parameter of the caller's. -- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; `RelayRoom.fetch` exposes non-upgrade routes, or public routes expose `RelayRows`, `closeBurrow`, or `onlineBurrows` RPC. Authenticated account-scoped internal calls for removal/online status remain required. +- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; `RelayRoom.fetch` exposes non-upgrade routes; or a public route reaches `RelayRows` or any `RelayRoom` method but the two upgrades, except authenticated, account-scoped `closeBurrow` on account-Worker removal and `onlineBurrows` on relay-Worker `GET /api/burrows`. - **FAIL IF** a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. Known gap: a failed or timed-out row sweep retains authenticated forwarding From 545b98842dc3140e76e4ef8fd08a4e423c92dd5b Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:35:11 -0700 Subject: [PATCH 3/4] Keep the complete RPC boundary within the existing spec budget --- docs/specs/security-hosted.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 672e74372..f8fb1447e 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -15,8 +15,8 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/one-time.test.ts`. -Known gap: `cookieAdmin` skips its Origin check on GET/HEAD, so a presented -foreign Origin on an admin read does not meet the cookie-route rule above. +Known gap: `cookieAdmin` skips Origin checks on GET/HEAD, admitting presented +foreign Origins contrary to the cookie-route rule above. ## Account boundary From 578c08824ed6a2439e8aa629a5ab92427a58e8da Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 2 Oct 2026 06:45:48 -0700 Subject: [PATCH 4/4] Restore one-time and Hosted contracts; fix cookieAdmin GET Origin The PR swapped accurate, load-bearing spec content for looser prose or pointers: the exact /connect/ CSP literal, the Laptop UI copy, the rendezvous frame shapes (leaving "`ct` is one base64url Noise message" dangling), the ceremony message shapes, the OneTimeState payloads, the account email-paths sentence, and the Durable Object migrations inventory. It also split two one-time.md bullet lists with blank lines, and parked seven audit findings in the specs as "Known gap" lines, two of which admitted the code fails a FAIL IF. Restore each from base, checked against headers.ts, one-time-wire.ts, e2e-ceremony.ts, one-time-runtime.ts, OneTimeConnection.tsx, App.tsx and the Wrangler configs. Two were stale and are corrected: the idle hint's middle sentence reads "Your phone can be on any network." under `phoneOnAnyNetwork`, and `ended` carries an optional `refusal`. Fix the cookie-route gap: `cookieAdmin` checked `Origin` only on state-changing methods, so a GET/HEAD presenting a sibling origin passed the security-hosted.md cookie FAIL IF's "any presented Origin but its own". It now refuses any presented foreign Origin on every method, and still requires one on state-changing requests. The new boundary test goes red against the old gate. With that fixed, revert the rationale line that pointed at the gap. Drop the other Known gap lines. The sweep-read failure needs a policy decision (fail closed vs. a tighter retry), and restored-token reinsertion, the stale /enroll approval, the open() keygen wait, and the phone client's missing pre-open deadline are real; all five go to issues. Approval body buffering (1 KiB bodyLimit), poll entitlement before the lock, last-slot poll ordering, the delayed-outcome direct wait, and the late wall adapter are not reachable failures. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 14 +---- docs/specs/hosted.rationale.md | 2 +- docs/specs/one-time.md | 76 +++++++++++++--------------- docs/specs/security-hosted.md | 6 --- hosted/server/account-gate.ts | 20 ++++---- hosted/server/tests/boundary.test.ts | 24 +++++++++ 6 files changed, 71 insertions(+), 71 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 06af8528f..40df20063 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -45,7 +45,7 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings` **Must link the account footer to the public Hosted privacy policy and terms.** -**Must show configured sign-in methods only.** Email supports sign-in and address-change actions. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. +**Must show configured sign-in methods only.** Email has send, existing-code, verify, resend, and change-address paths. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. **Must check the account on return to the page and serialize submitted actions.** Authenticated data remains in memory; login tokens never enter local storage. Only public identity fields are rendered, without provider images or external assets. The Voice tokens and Computers sections render only when `GET /api/voice/tokens` and `GET /api/relay/burrows` succeed, and neither failure fails the account page; a minted token stays in memory and is shown once. @@ -119,9 +119,6 @@ A session-gated route answers a session of an account no longer entitled with th - **Must answer 429 with `Retry-After` past the per-address limit on `signin/*` (`RELAY_SIGNIN_LIMIT`) and `setup/begin`/`finish` (`RELAY_SETUP_LIMIT`)**, before the body limit and any database read: 30 a minute, a ceremony's two routes sharing one budget (rationale). - **Must restore a token a refused `finish` spent on its original expiry, within the Burrow's cap, and never once that expiry has passed.** -Known gap: restored-token admission samples time before locking, permitting -expired reinsertion. - **Push.** The push routes keep `docs/specs/relay.md` -> "Web Push" and its "State files" upsert rules; a send is HTTPS from the Burrow to the relay Worker, independent of terminal transport. - **Must keep subscriptions in Postgres** (`hosted/server/dormouse-migrations/003_relay_push.sql`), keyed `(burrowId, deliveryId)`, every field bounded as self-host bounds it, deleted with their Burrow. **Must read the addresses a delivery moves off, drop rows, and prune 404/410 among the account's rows only.** @@ -153,9 +150,6 @@ The relay Worker serves `GET /ws/burrow` and `GET /ws/client` at the self-host p - **Must answer `RELAY_PING` with `RELAY_PONG` as the object's auto-response**, which never wakes it, reaches no handler, and is never forwarded. **A socket whose last auto-response is older than `RELAY_IDLE_TIMEOUT_MS` is gone**, retired with 1001; a socket that never pinged is never judged. It replaces the self-host heartbeat. Two gaps are accepted: a Burrow that never pings is never judged, since every build that can enroll with Hosted pings; and at the Client cap a backgrounded Pocket, its pings paused, counts as gone once silent that long — the cap is far above real use, and the Burrow reaps its session at 120 s anyway. - **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. `GET /api/burrows` reads `online` through account-scoped `onlineBurrows`. **Never expose either RPC or `RelayRows` as a public endpoint.** -Known gap: sweep-read failures violate the revocation bound; see -`docs/specs/security-hosted.md` -> "Relay boundary". - Source of truth: `relaySocketRoutes` in `hosted/server/relay-sockets.ts`; `relayRoom` / `RELAY_ROOM_PARAMS` / `RELAY_ROOM_SWEEP_MS` / `RELAY_ROW_READ_TIMEOUT_MS` in `hosted/server/relay-room-contract.ts`; `RelayRoom` in `hosted/server/relay-room.ts`; `RelayRows` in `hosted/server/relay-rows.ts`; `forwardUpgrade` / `refuseSocket` in `hosted/server/socket-room.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `exceedsRelayFrameBytes` in `remote-lib-common/src/remote/relay-routing.ts`; `durable_objects` and `migrations` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`. Pinned by `hosted/server/tests/relay-room.test.ts`, which also runs the routing cases every Relay passes (`remote-lib-common/test/harness/relay-parity.mjs`), and `hosted/server/tests/workers.test.ts`. ## Burrow enrollment @@ -187,10 +181,6 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that 403 f - **Must remove by deleting the Burrow's row**, its setup tokens and setup challenges cascading, so `burrowByToken` finds nothing on any relay route, **then close its live socket** ("Relay sockets"). - **Must take the `/enroll` fragment before render and erase it from history**, as `/connect/` does, and hold the code in memory only: through email sign-in and back, never into storage. A fragment change on `/enroll` takes the new code without reloading; elsewhere it does nothing. Provider sign-in leaves the page, so the user opens the link again. -Known gaps: approval buffers bodies before auth/rate gates; poll expiry and -entitlement checks precede the account lock; racing last-slot polls can answer -capacity before redeemed status. Stale approval completion can clear a newer link. - The page shows the code in the user-code role with "Approve only if Dormouse on your computer is showing this code right now." and offers Approve only within the recent-login window, Sign in again otherwise. The account page's Computers section lists each Burrow by its ID's first eight characters and enrollment date (the Relay keeps no name), with Remove. Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `cookieAdmin` in `hosted/server/account-gate.ts`; `ENROLLMENT_TTL_MS` / `RECENT_LOGIN_WINDOW` in `hosted/server/policy-constants.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`; `App` in `hosted/src/App.tsx`; `enrollUserCode` in `remote-lib-common/src/remote/enroll-code.ts`; `MAX_ENROLLED_BURROWS` in `remote-lib-common/src/remote/relay-common.ts`; `relayBindings` in `hosted/server/bindings.ts`; `ratelimits` and `ACCOUNT_ORIGIN` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`; `previewConfigs` in `hosted/scripts/preview.mjs`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/wire.test.mjs`. @@ -221,7 +211,7 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf **Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`, the relay's `RELAY_ENROLL_SECRET` and its VAPID pair) — names only, as Cloudflare exposes no value. Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. -**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. Migration inventories: `migrations` in the account and relay Wrangler configs. A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key (`pushConfigSmoke`: both VAPID secrets set, as one pair) and runs `oneTimeSmoke` on it, whatever the account's outcome; a failed smoke reports every failed part. +**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay has its own `v1` (`OneTimeRoom`) and `v2` (`RelayRoom`). A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and, once the relay's passes, requires its `/api/push/config` to answer a key (`pushConfigSmoke`: both VAPID secrets set, as one pair) and runs `oneTimeSmoke` on it, whatever the account's outcome; a failed smoke reports every failed part. **Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. Production repeats only the relay and voice smokes, up to six times 10 s apart (rationale). diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 7fd93fa1e..c2ce005e2 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -17,7 +17,7 @@ History sweep (sources checked 2026-09-22): Three origins (decided 2026-09-30): - Pocket (staged for the relay origin's root) and the `/connect/` page render untrusted terminal output. Script running on the account's origin could make any request the login cookie authorizes and read the answer, so `/connect/` moved to `relay.dormouse.sh` and the account kept its origin. -- Sibling origins under `dormouse.sh` are same-site, not same-origin: `SameSite=Lax` does not stop a browser from attaching the account's cookie to a request a `relay.` or `voice.` page makes to `hosted.`. The exact-`Origin` check refuses state-changing cookie requests; admin reads retain the gap recorded in the spec. +- Sibling origins under `dormouse.sh` are same-site, not same-origin: `SameSite=Lax` does not stop a browser from attaching the account's cookie to a request a `relay.` or `voice.` page makes to `hosted.`. The exact-`Origin` check on every cookie route is what refuses those requests. - No released desktop build bakes `hosted.dormouse.sh` (v1.1.0, the last release, predates one-time and managed voice), so the routes moved off it with no compatibility shim. ## Production releases diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index 31b33667b..aa07ed878 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -71,13 +71,13 @@ Source of truth: `formatOneTimeLinkUrl` / `parseOneTimeLinkUrl` / `BurrowRuntime` has a reader for it. Two WebSocket routes on the one-time origin: `ONE_TIME_WS_ROUTES.burrow` (`/api/one-time/burrow`) mints a room, and `ONE_TIME_WS_ROUTES.client` (`/api/one-time/client?room=`) joins one. -**Must send one exact-key JSON frame per message, forwarded verbatim by the room.** Canonical types and guards own its fields: +**Must send each message as one JSON frame with exact keys**, forwarded verbatim by the room: -| Frame | Direction | Contract | +| Frame | Direction | Shape | | --- | --- | --- | -| `OneTimeRoomFrame` | room → Burrow, once, first | room identity and expiry in epoch ms whose whole seconds fit a uint32 | -| `OneTimeClientFrame` | phone → Burrow | Noise message 1, then transport | -| `OneTimeBurrowFrame` | Burrow → phone | Noise message 2, then transport | +| `OneTimeRoomFrame` | room → Burrow, once, first | `{t: 'one-time-room', roomId, expiresAt}`, `expiresAt` epoch ms whose whole seconds fit a uint32 | +| `OneTimeClientFrame` | phone → Burrow | `{t: 'one-time', step: 'init' \| 'transport', ct}` | +| `OneTimeBurrowFrame` | Burrow → phone | `{t: 'one-time', step: 'response' \| 'transport', ct}` | `ct` is one base64url Noise message, bounded as on the relay envelope. Each end keeps its open socket alive with the relay socket's heartbeat @@ -93,11 +93,12 @@ no deadline. 5 minutes). The join and the confirmation finish by its expiry, and the direct path's `DIRECT_ONLY_DEADLINE_MS` (30 s) after the outcome ends inside the room's hard deadline, `expiresAt + ONE_TIME_EXPIRY_GRACE_MS` (45 s). -- **Must send one padded `control` request phone → Burrow, then one outcome Burrow → phone** - on the Noise session (`docs/specs/relay.md` -> "E2E framing"). - `OneTimeRequestV1` carries the confirmation digits and a `ONE_TIME_DEVICE_LABELS` - label; `OneTimeOutcomeV1` carries the approved Burrow label or a - `ONE_TIME_DENIAL_CODES` refusal. Their guards own exact shapes and values. +- **The ceremony's messages are padded `control` messages** on the Noise session + (`docs/specs/relay.md` -> "E2E framing"): `OneTimeRequestV1 {code, label}` + phone → Burrow, the page sending a `label` from `ONE_TIME_DEVICE_LABELS`, then one + `OneTimeOutcomeV1` — `{ok: true, burrowLabel}`, or + `{ok: false, code}` with `code` one of `ONE_TIME_DENIAL_CODES` + (`user-denied`, `confirmation-mismatch`, `link-expired`, `burrow-error`). The room closes a socket with one of six codes, each exported with a `_REASON`: @@ -127,18 +128,17 @@ resumes. | `OneTimeState` | Meaning | | --- | --- | | `opening` | minting the keypair, then waiting up to `ONE_TIME_OPEN_TIMEOUT_MS` (8 s) for the room frame | -| `waiting` | the link is live; its published expiry is its last live millisecond | -| `confirming` | a phone's request awaits the approval modal | -| `connecting` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | -| `connected` | both directions are direct and the rendezvous is closed | -| `ended` | terminal | +| `waiting {url, expiresAt}` | the link is live; `expiresAt` is its last live millisecond | +| `confirming {label, expiresAt}` | a phone's request awaits the approval modal | +| `connecting {label}` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | +| `connected {label, since}` | both directions are direct and the rendezvous is closed | +| `ended {reason, refusal?}` | terminal; `refusal` is `network-not-allowed`'s alone | `unavailable {reason}` and `idle` complete the type for the service, which decides them; a runtime never enters either. - **Must open the socket through the host's factory with no `Origin` header**, on the origin's Burrow route with `http` replaced by `ws`. - - **The first message must be one `OneTimeRoomFrame`**, else `unreachable`. The link's expiry is the earlier of the runtime's own `now + ONE_TIME_LINK_TTL_MS` and the room's `expiresAt`, floored to whole seconds. @@ -186,9 +186,6 @@ decides them; a runtime never enters either. | `rendezvous-lost` | any other close before the switch | | `burrow-error` | room close `4015`, a protocol violation, an application message over the rendezvous, a room past the message cap, or a local failure | -Known gap: the open deadline and End settle state, but `open()` still awaits -pending key generation before returning. Its phase guard prevents a late socket. - Source of truth: `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`; `directOnly`, `onDirectOnlyBroken`, and `directDeadlineAt` in @@ -214,7 +211,6 @@ at most one session**, on `ClientSessionCore`, direct or not at all. - **Never open a socket outside `connectOnce`**, which runs once per client and opens none for an expired link. - - **Never import store, passkey, push, or worker code** (the static's rule: `docs/specs/remote-security-model.md` -> "One-time connection"). - Every protocol-v1 method refuses until both directions are direct (Direct @@ -225,10 +221,6 @@ at most one session**, on `ClientSessionCore`, direct or not at all. - After the switch (the same carve-out), channel loss, the laptop's goodbye, or the idle deadline reaches `setOnEnded` once; `close()` reports nothing. -Known gap: WebCrypto or an unopened socket can wait without a timer. A delayed -approved outcome also starts a fresh direct wait rather than using the room's -remaining lifetime. These paths do not meet the deadline contract above. - Every failure resolves `{ok: false, message}` with fixed copy: | Failure | Copy | @@ -345,15 +337,18 @@ the root, and checks the copy. **Serving.** The relay Worker answers `/connect`, `/connect/`, and `/connect/assets/*` from its assets, with no SPA fallback; an HTML answer under `/connect/assets/` and any other path under `/connect/` is a 404. -A hashed file there is cached immutably. Other relay responses follow -`docs/specs/security-hosted.md` -> "Relay boundary". - -- **Must confine page resources to the relay origin's `/connect/`, scripts and - external styles to `/connect/assets/`, and connections to its rendezvous client - route.** Inline styles and WebAssembly compilation are allowed; inline scripts - are not. Images may use data/blob URLs and media only blob URLs. -- **Must default-deny other sources and prohibit workers, objects, framing, - forms, base URLs, and popups.** The policy builder owns directive syntax. +A hashed file there is cached immutably. Everything under `/connect` carries +this policy, from the relay's `APP_ORIGIN` (``; `` with `http` +replaced by `ws`); every other relay response carries Pocket's policy or +`RUNS_NOTHING_POLICY` (`docs/specs/security-hosted.md` -> "Relay boundary"): + +``` +default-src 'none'; script-src /connect/assets/ 'wasm-unsafe-eval'; +style-src /connect/assets/ 'unsafe-inline'; img-src /connect/ data: blob:; +font-src /connect/; media-src blob:; connect-src /api/one-time/client; +worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'; +object-src 'none'; sandbox allow-scripts allow-same-origin +``` - **An `APP_ORIGIN` that is not exactly `scheme://host[:port]` of plain host characters gets `RUNS_NOTHING_POLICY` instead**: the URL parser admits `;`, @@ -361,9 +356,6 @@ A hashed file there is cached immutably. Other relay responses follow - **The sandbox keeps `allow-same-origin`**, and Chrome's warning about the pair stands (rationale). -Known gap: teardown can precede a pending wall completion, leaving a late -adapter attached after cleanup. - Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `lib/src/remote/one-time-app/OneTimeApp.tsx`; `takeOneTimeLinkUrl` / `reloadOnNewLink` in @@ -464,13 +456,13 @@ sits in the Baseboard's right cluster (`docs/specs/layout.md` -> "Baseboard"). | State | The panel shows | Actions | | --- | --- | --- | -| `idle` | the **One-time connection** button; one-off, allowed-network, no-account hint | the button opens | +| `idle` | the **One-time connection** button; "Open a link on your phone for a one-off connection. Your phone must be on an allowed network. No account needed.", its middle sentence "Your phone can be on any network." under `phoneOnAnyNetwork` | the button opens | | `unavailable` | the button disabled, the reason's copy for the hint | — | -| `opening` | progress | Cancel | -| `waiting` | QR and selectable link; single-phone hint and expiry in minutes | Copy link, New link, Cancel | -| `confirming` | instruction to type the phone's two digits in the dialog | Cancel | -| `connecting` | direct-connection progress | Cancel | -| `connected` | phone label and full-terminal-control notice | End | +| `opening` | "Getting a link…" | Cancel | +| `waiting` | the link as a QR code and as selectable text; "Good for one phone. Expires in N min." | Copy link, New link, Cancel | +| `confirming` | "Type the two digits your phone shows into the dialog." | Cancel | +| `connecting` | "Connecting directly…" | Cancel | +| `connected` | the phone's label, then "has full control of your terminals." | End | | `ended` | the reason's sentence, a refusal's from `pathRefusalSentence` | New link, Done | - **The panel renders the service's state and owns only its busy and error**; a diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index f8fb1447e..1bd9538c8 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -15,9 +15,6 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/one-time.test.ts`. -Known gap: `cookieAdmin` skips Origin checks on GET/HEAD, admitting presented -foreign Origins contrary to the cookie-route rule above. - ## Account boundary - **FAIL IF** the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter. @@ -68,9 +65,6 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; `RelayRoom.fetch` exposes non-upgrade routes; or a public route reaches `RelayRows` or any `RelayRoom` method but the two upgrades, except authenticated, account-scoped `closeBurrow` on account-Worker removal and `onlineBurrows` on relay-Worker `GET /api/burrows`. - **FAIL IF** a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. -Known gap: a failed or timed-out row sweep retains authenticated forwarding -until a later sweep, contrary to the next-sweep revocation obligation above. - Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`. ## Deployment boundary diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts index 2ff373f4d..d1ec0308f 100644 --- a/hosted/server/account-gate.ts +++ b/hosted/server/account-gate.ts @@ -29,11 +29,12 @@ export interface AccountLogin { } /** - * The account Worker's cookie routes' gate: a state-changing request carries - * exactly this origin — same-site pages, the relay and voice origins among - * them, share the login cookie — then the Better Auth handler's `get-session` - * answers the login (401 without one), and only the verified admin passes - * (`refuse` answers anyone else). Sets `login`. + * The account Worker's cookie routes' gate: a presented `Origin` is exactly + * this origin, and a state-changing request must present one — same-site + * pages, the relay and voice origins among them, share the login cookie — + * then the Better Auth handler's `get-session` answers the login (401 + * without one), and only the verified admin passes (`refuse` answers anyone + * else). Sets `login`. */ export function cookieAdmin( host: (c: Context) => AccountHost, @@ -41,11 +42,10 @@ export function cookieAdmin( ): MiddlewareHandler<{ Variables: { login: AccountLogin } }> { return async (c, next) => { const origin = new URL(c.req.url).origin; - if ( - c.req.method !== "GET" && - c.req.method !== "HEAD" && - c.req.header("origin") !== origin - ) + const presented = c.req.header("origin"); + // A read may omit `Origin`; nothing may present a foreign one. + const safe = c.req.method === "GET" || c.req.method === "HEAD"; + if (presented === undefined ? !safe : presented !== origin) return c.json({ message: "Invalid origin." }, 403); const headers = new Headers(); for (const name of ["cookie", "cf-connecting-ip"]) { diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 4b7fa0629..32556980d 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -496,6 +496,30 @@ test("the relay bundle reads no cookie and never asks auth", () => { expect(readFileSync("server/account-gate.ts", "utf8")).toMatch(/["'`]cookie["'`]/); }); +test("the cookie gate refuses a presented foreign Origin on every method", async () => { + const origin = "https://account.example.test"; + const app = new Hono(); + const gate = cookieAdmin( + () => ({ + databaseUrl: "postgres://user:pass@127.0.0.1:9/none", + auth: async () => + Response.json({ user: { id: "admin", email: ADMIN_EMAIL, emailVerified: true }, session: {} }), + }), + () => new Response(null, { status: 403 }), + ); + app.on(["GET", "HEAD", "POST"], "/gated", gate, (c) => c.body(null, 204)); + const sibling = "https://relay.example.test"; + for (const method of ["GET", "HEAD", "POST"]) { + const send = (headers: Record) => + app.request(`${origin}/gated`, { method, headers }); + expect((await send({ origin: sibling })).status, method).toBe(403); + expect((await send({ origin: "null" })).status, method).toBe(403); + expect((await send({ origin })).status, method).toBe(204); + // Only a read may omit `Origin`. + expect((await send({})).status, method).toBe(method === "POST" ? 403 : 204); + } +}); + test("the cookie gate needs no login creation time; approval reads it and fails closed", async () => { // `get-session` as the packed adapter answers it, `createdAt` as given. const host = (createdAt?: unknown) => () => ({