diff --git a/apps/documentation/src/content/docs/decisions/0014-control-plane-admission.md b/apps/documentation/src/content/docs/decisions/0014-control-plane-admission.md new file mode 100644 index 0000000..3e1ee76 --- /dev/null +++ b/apps/documentation/src/content/docs/decisions/0014-control-plane-admission.md @@ -0,0 +1,135 @@ +--- +title: ADR-0014 — The control plane admits requests by the name they used, not the socket they arrived on +--- + +# ADR-0014 — The control plane admits requests by the name they used, not the socket they arrived on + +**Status:** Proposed +**Date:** 2026-10-12 +**Spec:** [Control-plane hardening](/design/control-plane-hardening/) (NIW2-309) + +## Context + +The panel and the control plane (`/__laqi/*`, including the SSE stream) are mounted +only when laqi listens on a loopback address, and the design has so far treated that +binding as the protection: nothing on the network can reach `127.0.0.1:8000`. + +A browser on this machine can. Any website can make the browser send a request to +`http://evil.example:8000` and, if `evil.example` resolves to `127.0.0.1` at that +moment (DNS rebinding), the request lands on laqi's socket, same-origin with the +attacker's page, with every `GET` readable — mocks, state, the live request stream, +and until this change the `--share` bearer token. Chrome 94+ blocks this from public +non-secure pages (Private Network Access); Firefox and Safari do not, which is why +Vite shipped `server.allowedHosts` in January 2025 after +[CVE-2025-24010](https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6). +The MCP specification makes the same demand of every local HTTP server. + +Writes were guarded by an `Origin` allowlist, but one that accepted any +`http://localhost:*` origin and parsed JSON from `text/plain` bodies — so a +compromised dependency in the frontend on `localhost:3000` could rewrite the +developer's mock files without a preflight. + +The control plane has no authentication and the README promises it stays usable +from `curl`. + +## Decision + +Admission to everything under `/__laqi` is decided by headers the attacker cannot +choose, in three independent layers, with no secret: + +1. **Host allowlist, every method.** The `Host` hostname must be a loopback name + (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`) or a name the developer + listed with `--allowed-host`. Hostname only; the port is not checked. +2. **Exact served origin, every method.** When `Origin` is present, its authority + must be byte-equal to the request's `Host` (scheme `http` or `https`). `null` + is rejected. On `GET` this matters for `/events`. +3. **Writes need two more signals.** `Sec-Fetch-Site` must be `same-origin`, `none` + or absent; a body must be `application/json` and at most 8 MiB. + +A request with no `Origin` and no `Sec-Fetch-Site` is admitted. Every browser sends +`Origin` on a cross-origin request and every browser that implements Fetch Metadata +sends `Sec-Fetch-Site`; a request with neither is not a browser, it is the developer +running `curl`. + +There is no per-session token, cookie or custom header requirement. + +## Why + +**The name, not the socket.** The one thing a rebinding page cannot control is the +`Host` the browser writes: it is the name the page used. A loopback name or an IP +literal is never looked up, so it cannot be rebound. This is the same reasoning as +Vite's, webpack-dev-server's and the MCP TypeScript SDK's `hostHeaderValidation`, +and all three are hostname-only. + +**Why not the port on `Host`.** A different local port on a loopback name means a +port forward (SSH, VS Code, Docker), which is the developer's own doing. The port +adds nothing against rebinding and would reject those users. The port is enforced +where it matters, on `Origin`, by requiring equality with `Host`. + +**Why the exact origin and not "any localhost".** `localhost:3000` is another +program. The developer's trust in laqi does not extend to every dependency of every +dev server on the machine. "Same authority as Host" is the narrowest rule that still +works for a port-forwarded panel. + +**Why `Content-Type: application/json` is a security control.** A browser sends a +cross-origin `POST` with `text/plain` without asking; with `application/json` it must +preflight, and the control plane answers no preflight. So even if the two header +checks were wrong, the body could not be delivered. OWASP lists this under custom +headers and non-simple content types. + +**Why no secret.** A session token would have to be pasted on every `curl` call or +the README's control-plane section becomes false. Local processes that could obtain +the token are the developer; they can edit the files directly. The layers above +already close every browser path. + +## Alternatives rejected + +- **Per-session CSRF header injected into the panel's HTML** (Jupyter's model). + Breaks scripting, holds a second secret in the panel, closes nothing more. +- **Exact `host:port` on `Host`** (the partial patch). Rejects port forwards for + no gain. +- **Treat a missing `Origin` as cross-origin.** Turns the API panel-only. +- **`--allowed-host true` to disable the check** (Vite offers it). With a + code-execution route on this server, the blast radius is larger than source + disclosure; a listed name is always possible. +- **Rely on Chrome's Private Network Access.** Firefox and Safari users are the + ones exposed. + +## Consequences + +**In favour:** + +- Rebinding fails on every route, SSE included, with a 403 that names the host and + the flag that would allow it. +- Writes come only from the panel's own origin or from non-browser clients on this + machine. +- `curl`, scripts and the in-process MCP keep working unchanged, except that a body + must say it is JSON. + +**Against:** + +- `curl -d` without `-H 'Content-Type: application/json'` now gets 415. The README + examples change; so do any user scripts. +- Developers behind a proxy that keeps the public `Host` (Codespaces, Gitpod) must + pass `--allowed-host`. The 403 message tells them. +- Browser code on another local origin can no longer drive the control plane. No + documented use exists; reopen on a report. +- `localhost:5173` HMR development needs the Vite proxy to rewrite `Host` and drop + `Origin`. + +## Non-goals + +- Authenticating the control plane. It stays loopback-only and unauthenticated. +- Guarding the mock routes. They serve phones and tunnels and answer any `Host`. +- Private Network Access / Local Network Access preflights. + +## What would reopen this + +- A browser that ships cross-origin writes without `Origin` and without + `Sec-Fetch-Site`. Then the no-Origin admission has to go, and the control plane + needs a token. +- A supported, documented use of the control plane from another local origin. Then + an `--allowed-origin` list, not a return to "any localhost". +- A control plane reachable off-machine by design (a phase-2 relay with an + authenticated panel). Then admission is authentication, and this ADR is + superseded. diff --git a/apps/documentation/src/content/docs/decisions/0015-share-token-disclosure.md b/apps/documentation/src/content/docs/decisions/0015-share-token-disclosure.md new file mode 100644 index 0000000..c840a9d --- /dev/null +++ b/apps/documentation/src/content/docs/decisions/0015-share-token-disclosure.md @@ -0,0 +1,75 @@ +--- +title: ADR-0015 — The share token is never returned by a GET +--- + +# ADR-0015 — The share token is never returned by a GET + +**Status:** Proposed +**Date:** 2026-10-12 +**Spec:** [Control-plane hardening](/design/control-plane-hardening/) (NIW2-309) + +## Context + +`laqi --share` generates a bearer token that is the only thing standing between the +internet and the developer's mocks ([ADR-0007](/decisions/0007-public-url/), item 1). +The terminal prints it at startup (item 5). The panel also shows it, masked, in the +magenta band with `Reveal token` and `Copy curl` ([F7](/design/flows/07-share-publicly/)). + +To feed the band, `GET /__laqi/api/status` returned `share.token` to any admitted +caller, with the comment "it's local-only and it's where the developer is going to +copy it from". The status endpoint is polled by the panel on every refresh. The +audit showed that a DNS-rebinding page could read it; the admission ADR closes that +path, but the token would still be the payload of a routine, cacheable `GET` — one +admission bug, one same-origin XSS in the panel, or one screenshot of the Network tab +away from the internet. + +## Decision + +- `GET /__laqi/api/status` reports `share.hasToken: boolean` and never the token. +- The panel obtains the token only when the developer clicks `Reveal token` or + `Copy curl`, through `POST /__laqi/api/share/token`, which answers + `Cache-Control: no-store` and is subject to every write-admission rule (exact + origin, `Sec-Fetch-Site`, no cross-origin preflight ever granted). The panel drops + the value when the band hides it again. The mask is a fixed `lq_••••••••••••`. +- The terminal remains the primary disclosure, unchanged. + +## Why + +A secret should be the result of an action, not a side effect of a poll. A `POST` +that returns the secret is reachable only by a same-origin page or a non-browser +client on this machine, and is never cached. The F7 flow — reveal on a shared +screen, copy a ready `curl` — survives intact, which the stricter alternative does +not. + +## Alternatives rejected + +- **Terminal only; the band says "printed in your terminal".** Zero API surface for + the secret, but it deletes the Reveal / Copy curl affordances F7 designs for the + exact situation where the panel is on a shared screen. +- **Keep it in `/api/status` now that rebinding is closed.** Defence in depth is + the point: the token is the one credential that works from outside the machine. +- **Embed the token in the panel's `index.html`.** Same exposure as a `GET`, plus a + cacheable document. +- **A `GET /api/share/token` with the same admission rules.** Admission is the same, + but a `GET` is cacheable, prefetchable and linkable; a `POST` is none of those. + +## Consequences + +**In favour:** no `GET` under `/__laqi` carries a secret; a bug in admission or in +the panel degrades to mock disclosure, not tunnel takeover. + +**Against:** `share.token` disappears from `/api/status`; any script that read it +moves to the `POST` or the terminal. `ShareBand` changes shape (`hasToken`, a fixed +mask, a fetch on click), which is a small panel change this ticket carries. + +## Non-goals + +- Rotating the token, `--token-file`, or accepting it anywhere but the + `Authorization` header. Separate concerns. +- Hiding the token from the terminal. ADR-0007 item 5 requires it there. + +## What would reopen this + +A panel that authenticates its own session (a phase-2 relay with login). Then the +token belongs to the session, and whether the panel may show it is a session +question, not a transport one. diff --git a/apps/documentation/src/content/docs/design/control-plane-hardening.md b/apps/documentation/src/content/docs/design/control-plane-hardening.md new file mode 100644 index 0000000..5682163 --- /dev/null +++ b/apps/documentation/src/content/docs/design/control-plane-hardening.md @@ -0,0 +1,920 @@ +--- +title: Control-plane hardening — design spec +--- + +# Control-plane hardening — design spec + +**Status:** proposed, not implemented. +**Date:** 2026-10-12 +**Ticket:** [NIW2-309](https://linear.app/niway/issue/NIW2-309) — Harden the control plane against DNS rebinding and CSRF (audit umbrella NIW2-302) +**Scope of ownership:** `packages/server` (except module routes, which belong to NIW2-310), `apps/cli/src/{serve,index,tunnel,watcher,editor-assets}.ts`, plus the minimal panel adaptation named in [File boundaries](#cross-ticket-dependencies-and-file-boundaries). + +This document turns the control-plane findings of the laqi audit into contracts an +implementer can build and a reviewer can check. Every finding was re-located on the +read-only export of `origin/main` taken on 2026-10-12; line numbers below refer to +that snapshot. **Nothing in the ticket is already fixed on main**: the audits were +written against `feat/hero-demo`, and the only drift is a shift of a few lines in +`control-plane-app.ts` (the Origin guard moved from 279-303 to 285-305). + +Normative statements use **must** / **must not**. Code blocks marked _illustrative_ +show intent, not final declarations. Claims not verified in code or by running +anything are marked **unverified**. + +## In one minute + +### Summary + +The panel and the control plane are bound to loopback and trust that binding. That is +not enough: a browser on this machine will happily carry a request from any website +to `127.0.0.1:8000` if that website's DNS name rebinds to loopback, and every `GET` +under `/__laqi` answers it — including `/api/status`, which hands over the `--share` +bearer token. Writes are guarded only by an Origin allowlist that accepts any +`localhost:*` port and parses JSON regardless of `Content-Type`. The panel ships no +CSP and can be framed. On the tunnel, the rate limiter counts anonymous requests +against the owner's quota and sorts every bucket under pressure. SSE fan-out is +unbounded. The mock app can throw and lies to the log. The CLI kills the server when +a reload finds no endpoints, reloads twice per panel write, and never notices the +tunnel dying. + +This spec makes admission to `/__laqi` depend on **the name the request used, not the +socket it arrived on** (Host allowlist on every route, SSE included), makes writes +accept only the exact served origin with three independent signals, takes the share +token out of every `GET`, adds a CSP the React panel can live with, reorders the +tunnel's rate limit behind authentication, bounds SSE and request bodies, makes the +mock app never throw, and makes the CLI's reload and tunnel lifecycle degrade instead +of exit. + +### Decisions + +| # | Decision | Why | ADR | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| D1 | Every request under `/__laqi` (panel, API, `/events`) **must** carry a `Host` whose hostname is a loopback name (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`) or a name the developer listed with `--allowed-host`. Hostname-only; the port is not checked on Host. | A rebinding page cannot choose the `Host` the browser sends. Hostname-only is what Vite, webpack-dev-server and the MCP TypeScript SDK do; checking the port breaks SSH/VS Code port forwards for no security gain. | [ADR-0014 control-plane admission](/decisions/0014-control-plane-admission/) | +| D2 | An `Origin`, when present on **any** method, **must** name the same authority as `Host` (scheme `http` or `https`). A request with no `Origin` is admitted. | "Exact served origin" closes writes from other `localhost:*` apps and cross-origin `EventSource` on `/events`. Browsers always send `Origin` on cross-origin requests; curl, scripts and the in-process MCP never do. | same | +| D3 | A write (`POST`/`PUT`/`PATCH`/`DELETE`) additionally **must** have `Sec-Fetch-Site` ∈ {`same-origin`, `none`, absent}, and a body **must** be `Content-Type: application/json` (415 otherwise) and ≤ 8 MiB (413 otherwise). | Three signals that fail independently. A non-safelisted content type forces a CORS preflight the control plane never grants, so a cross-origin page cannot even deliver the body. | same | +| D4 | No per-session secret, cookie or CSRF token on the control plane. | It would break the documented `curl` scripting flow and protect nothing D1-D3 do not, because a local process is the developer. | same | +| D5 | `share.token` leaves `GET /api/status`. The terminal remains the primary place; the panel obtains it on demand through `POST /__laqi/api/share/token`, which answers `Cache-Control: no-store`. | No `GET` on the control plane may return a secret, so a single admission bug or a same-origin XSS cannot turn into token theft. F7's Reveal / Copy curl keep working. | [ADR-0015 share-token disclosure](/decisions/0015-share-token-disclosure/) | +| D6 | Panel documents get a strict CSP (`'self'` only, `data:` for images and fonts, `frame-ancestors 'none'`) plus `X-Frame-Options`, `nosniff`, `Referrer-Policy`; every JSON response gets `nosniff`, `no-referrer`, `Cache-Control: no-store`. | Clickjacking a Delete or a module Confirm is free to prevent. The built panel has no inline script or style, so no nonce machinery is needed. | none | +| D7 | On the tunnel: authenticate **before** counting against the per-IP and global quota; failed authentications are counted in a separate, small per-IP bucket; bucket eviction is O(1) (a new key at the ceiling shares the global bucket instead of sorting). | An anonymous flood must not lock the owner out of the 1200/min quota, and an attacker rotating `CF-Connecting-IP` must not make every request sort 10 000 entries. | none | +| D8 | SSE: at most 8 concurrent streams (503 beyond), one serialised JSON per event shared by all streams, a bounded per-stream queue (256) that drops the oldest and tells the client with an `overflow` event, a 15 s heartbeat comment. | Memory is bounded by laqi's own numbers, not by the transport's behaviour; the panel can resync on `overflow` (NIW2-316). | none | +| D9 | Mock app: `app.onError` answers the JSON envelope with 500; the request event is emitted **after** the response exists; null-body statuses never throw; 1xx answers 500 with a message. | The log and the real result must agree; a thrown error today is a bare `Internal Server Error` nobody can act on. | none | +| D10 | The public (tunnel) surface's 404 **must not** list routes, with or without a token. | Route enumeration from a URL bots scan. | none | +| D11 | A reload **must never** exit the process; only boot does. A reload whose loaded content is byte-identical to the current one is a no-op (no rebuild, no event, no reprint). A tunnel that dies after start is reported and the band clears. | Deleting the last endpoint in the panel kills server and tunnel today; a panel write reloads twice; a dead tunnel keeps advertising a URL. | none | + +### Open questions for the owner + +1. **`--allowed-origin`.** D2 rejects browser writes from any other local origin (a frontend at `localhost:3000` calling `PUT /__laqi/api/state` from its own page). Nothing documents that use, so this spec does not offer an escape hatch. Recommendation: do not add one now; reopen on a user report. +2. **Where `allowedHosts` lives.** This spec ships the CLI flag `--allowed-host` (repeatable). A `laqi.config.json` key would be convenient for Codespaces teams but widens what a cloned repository can decide, which is NIW2-315's trust boundary. Recommendation: flag only in this ticket; the config key is NIW2-315's call. +3. **Reveal in the panel vs terminal only.** D5 keeps the panel's Reveal / Copy curl through a `POST`. The stricter alternative removes the token from the API entirely and the band says "printed in your terminal". Recommendation: keep D5; F7 is a designed flow. +4. **SSE numbers.** 8 streams, 256 queued events, 15 s heartbeat are defaults with no measurement behind them. Recommendation: accept, and make them constants in one place. +5. **Tunnel auto-restart.** D11 reports a dead tunnel and stops. One automatic retry would be cheap. Recommendation: no retry in this ticket; a retry that fails silently is worse than a notice. +6. **CSP enforcing vs `Report-Only` for one release.** Recommendation: enforce from the start, gated by the static test in [F3](#f3-panel-and-api-response-headers) and the manual flow check in [Rollout](#rollout-and-order-of-implementation). +7. **Body limit 8 MiB.** Large enough for an inline OpenAPI document pasted in the panel, small enough that a stray local script cannot exhaust memory. Recommendation: accept. + +### Out of scope + +- Module execution approval (`prepare`/`confirm`, tokens, child env) — **NIW2-310**. The admission rules here apply to those routes like any other, nothing more. +- Trusting `laqi.config.json` from the current directory, fail-closed config, unknown keys — **NIW2-315**. +- Rejecting a body on 204/304 or a 1xx status **at load time** — **NIW2-315**. This ticket only makes the runtime never throw on one. +- The panel's reaction to an `overflow` event (resync) and other editor changes — **NIW2-316**. This ticket changes the panel only where the API contract forces it (see [boundaries](#cross-ticket-dependencies-and-file-boundaries)). +- The `hono` bump — **NIW2-312**. Nothing here depends on a newer Hono than 4.12.3. +- In-memory state cache for `getState()` on every request — **NIW2-313**. +- Authentication on the control plane, `cors: '*'` on the loopback mock server (server audit S7), and Private Network Access / Local Network Access preflights: documented, not changed. +- The reusable guidance hub (`general-knowledge`) has no page on DNS rebinding, CSRF or CSP today; this spec cites external sources and does not create one. + +## Threat model + +Who can reach the loopback listener: + +| Caller | Can today | Should | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| The developer's own browser tab on the panel (`http://localhost:8000/__laqi`) | read + write | read + write | +| `curl`, scripts, editors, the README's documented `PUT /__laqi/api/state` flow (no `Origin`) | read + write | read + write — they run as the developer and could edit the files directly | +| The MCP server | in-process (`new Project(...)`, `packages/mcp/src/server.ts:56`); never HTTP | unchanged | +| A page on **any other website**, after rebinding its DNS name to `127.0.0.1` | read everything, incl. the share token | nothing: 403 on every route | +| A page on **another local origin** (`localhost:3000`, a dev server with an XSS or a bad dependency) | write (Origin allowlist accepts any localhost port); read blocked only by CORS | nothing: 403 on every route | +| Any website framing the panel invisibly | can frame it | cannot | +| Anyone with the tunnel URL and no token | can burn the owner's whole quota, enumerate routes on 404 | gets 401 cheaply, then 429 from its own bucket; learns nothing | + +Why rebinding is still real in 2026: Chrome 94+ blocks requests from public, +non-secure pages to private addresses (Private Network Access), so Chrome users are +mostly covered; Firefox and Safari are not, and the Vite advisory that introduced +`server.allowedHosts` says exactly that ([CVE-2025-24010](https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6)). +The MCP specification requires the same of every local HTTP server: "Servers MUST +validate the Origin header on all incoming connections to prevent DNS rebinding +attacks" ([MCP transports, Security Warning](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)). + +## Vocabulary + +| Term | Meaning here | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Loopback surfaces | Everything mounted under `/__laqi` on the local listener: the panel's static files (`createEditorApp`) and the control plane (`createControlPlaneApp`). | +| Authority | The `host[:port]` part of a URL or a `Host` header, after WHATWG URL normalisation (`new URL('http://' + value).host`). | +| Loopback name | `localhost`, any `*.localhost`, an IPv4 literal in `127.0.0.0/8`, or `[::1]`. Literals are never looked up, so they cannot be rebound. | +| Admission | The set of checks a request passes before any `/__laqi` handler runs (D1-D3). | +| Write | A request whose method is `POST`, `PUT`, `PATCH` or `DELETE`. | +| Public surface | The second listener that the tunnel points at (`createPublicApp`), where the control plane does not exist. | + +## F1 — Admission to the loopback surfaces + +### Current behaviour on main + +- `packages/server/src/control-plane-app.ts:285-305` — the only guard. It runs on + writes (`POST`/`PUT`/`DELETE`; `PATCH` is not listed) **that carry an `Origin`**, and + accepts any `http:` origin whose hostname is `127.0.0.1`, `localhost` or `[::1]`, + on any port. `GET`s and `/events` (`:553-573`) are not checked at all. +- No `Host` check exists anywhere in `packages/server/src` or `apps/cli/src` (grepped). +- `apps/cli/src/serve.ts:554-559` mounts the panel and the control plane whenever the + bind host is loopback. `editor-assets.ts:57-76` serves files with only a + `Content-Type`. +- Bodies are read with `c.req.json()` (`control-plane-app.ts:338, 437, 511` and the + `readJson` helper at `:599-607`) without looking at `Content-Type`; no size limit. +- The panel's client sends `Content-Type: application/json` on every request with a + body (`packages/editor/src/api.ts:39`) and uses relative URLs under `/__laqi` + (`:10`), so it is already compliant with D3. +- The `dev:hmr` workflow proxies `/__laqi/api` and `/__laqi/events` from Vite on + `:5173` to laqi on `:8000` without `changeOrigin` (`packages/editor/vite.config.ts:36-41`), + so those proxied requests arrive with `Host: localhost:5173` and + `Origin: http://localhost:5173`. + +### Threat + +1. **DNS rebinding (read + token theft).** The developer runs `laqi --share` and + opens `evil.example` in Firefox. The page's DNS answer flips to `127.0.0.1` with a + short TTL. From then on `fetch('/__laqi/api/status')` from that page is + same-origin with `evil.example:8000` and returns the tunnel URL and bearer token; + `/api/endpoints` returns every mock body; `/__laqi/events` streams every request + path. Writes are blocked only because the browser sends + `Origin: http://evil.example:8000`. +2. **CSRF from another local origin (write).** A compromised dependency in the + frontend served at `localhost:3000` posts to `http://localhost:8000/__laqi/api/endpoints` + with `Content-Type: text/plain` (a CORS simple request, no preflight) and the + guard admits it because `localhost` is on the list. Files in the developer's + project are rewritten; `PUT /api/state` flips what the app sees. +3. **Cross-origin `EventSource`.** A page on any origin can hold `/__laqi/events` + open. It cannot read it (no CORS headers), but it occupies a stream slot — which + matters once F6 caps them. + +### Designed behaviour + +A single middleware, `localOnly`, exported by `@laqi/server`, **must** run before +every handler under `/__laqi`, on both the panel and the control plane. It is applied +by `createControlPlaneApp` itself (so the package is safe when embedded on its own) +and by `serve.ts` on the editor app. Running it twice on control-plane routes is +accepted; it is a few string comparisons. + +**R1 — Host.** The request **must** carry a `Host` header (absent → 403). Its value +**must** parse as an authority (`new URL('http://' + value)` succeeds, with no +credentials and no path). Its hostname **must** be a loopback name or be listed in +`allowedHosts`. An `allowedHosts` entry matches the hostname exactly; an entry that +starts with a dot (`.app.github.dev`) matches the bare name and every subdomain. The +port is **not** checked on Host (D1). Otherwise: `403 { error: 'laqi-control-plane', +code: 'host-not-allowed', message: 'laqi does not answer to the host "". To allow it, start laqi with --allowed-host ' }`. + +**R2 — Origin, every method.** If an `Origin` header is present, it **must** parse as +a URL whose scheme is `http:` or `https:` and whose `host` (authority, after +normalisation) is byte-equal to the request's normalised Host authority. The literal +`null` fails to parse and is therefore rejected, as OWASP requires. Otherwise: +`403 { code: 'origin-not-allowed', message: 'cross-origin request rejected' }`. + +**R3 — Writes.** For `POST`, `PUT`, `PATCH`, `DELETE`: + +- if `Sec-Fetch-Site` is present it **must** be `same-origin` or `none`; `same-site` + and `cross-site` are rejected with `403 { code: 'origin-not-allowed' }`. (`same-site` + is what `localhost:3000` → `localhost:8000` produces: same registrable name, + different origin.) +- if the request has a body (a `Content-Length` other than `0`, or a + `Transfer-Encoding`), `Content-Type` **must** be `application/json` ignoring + parameters and case. Otherwise `415 { code: 'unsupported-media-type', message: 'the body must be sent as application/json' }`. +- the body **must not** exceed `MAX_BODY_BYTES = 8 * 1024 * 1024`; a declared + `Content-Length` above it is refused before a byte is read, an undeclared body is + counted as it streams and refused the moment it goes over. Otherwise + `413 { code: 'body-too-large', message: 'the body exceeds 8 MiB' }`. Hono's + `bodyLimit` from `hono/body-limit` does exactly this and is used (available in + 4.12.3; its Content-Length/Transfer-Encoding bypass was fixed in 4.9.7, + [CVE-2025-59139](https://db.gcve.eu/vuln/CVE-2025-59139)). + +**R4 — Navigation is admitted.** A top-level navigation to `/__laqi` from a link on +any site is a `GET` with no `Origin` and `Sec-Fetch-Site: cross-site`; R2 and R3 do +not touch `GET`, so the developer can still click a panel link from a chat. This is +the navigation exception the OWASP cheat sheet makes for Fetch Metadata. + +**R5 — No Origin, no Sec-Fetch-Site: admitted.** Every browser that can make a +cross-origin request sends `Origin` on it (all of them for writes; Fetch/XHR for +reads), and every browser that implements Fetch Metadata sends `Sec-Fetch-Site` +(Chrome 76, Firefox 90, Safari 16.4 — [caniuse](https://caniuse.com/mdn-http_headers_sec-fetch-site)). +A request with neither is a non-browser client on this machine: curl, a script, an +editor plugin. Those are the developer. Refusing them breaks the README's control +plane API section and protects nothing. + +**R6 — Mock routes are untouched.** The guard applies to `/__laqi` and `/__laqi/*` +only. The mock server keeps answering any `Host` (it serves phones over the LAN with +`--host 0.0.0.0`, and the tunnel). + +**R7 — Order.** Admission runs before the panel's `index.html`, before the +"panel not built" 503 page, before `/events`, and before every control-plane route +including the 404 catch-all. A rejected request **must not** reach a handler and +**must not** emit an event. + +**R8 — `allowedHosts` source.** `--allowed-host ` (repeatable; `parseArgs` +`multiple: true`) in `apps/cli/src/cli-surface.ts`. The effective list, when not +empty, is printed on the start screen as a `hosts` row. A config key is Open +question 2. + +**R9 — Chrome's and Safari's `*.localhost`.** Firefox 84+ and Chrome resolve +`*.localhost` to loopback internally; Safari follows `/etc/hosts` on older macOS +([WebKit 160504](https://bugs.webkit.org/show_bug.cgi?id=160504)). Either way the +name cannot be rebound because `.localhost` is a reserved TLD (RFC 6761), so it is in +the loopback set, as Vite does. + +### Interfaces (illustrative) + +```ts +// packages/server/src/local-guard.ts — illustrative +export type LocalGuardOptions = { + /** Extra hostnames (exact, or `.suffix`) the developer trusts. Default []. */ + allowedHosts?: readonly string[] +} + +export type AdmissionRefusal = + | { code: 'host-not-allowed'; status: 403 } + | { code: 'origin-not-allowed'; status: 403 } + | { code: 'unsupported-media-type'; status: 415 } + | { code: 'body-too-large'; status: 413 } + +/** Pure, for tests: why a request would be refused, or null. Body size is not here. */ +export function admissionRefusal( + headers: { host?: string; origin?: string; secFetchSite?: string; contentType?: string; hasBody: boolean }, + method: string, + options: LocalGuardOptions, +): AdmissionRefusal | null + +/** The Hono middleware: admissionRefusal + bodyLimit, in that order. */ +export function localOnly(options?: LocalGuardOptions): MiddlewareHandler + +export function createControlPlaneApp( + runtime: ControlPlaneRuntime, + options?: { guard?: LocalGuardOptions }, +): Hono +``` + +```ts +// apps/cli/src/serve.ts — illustrative mount +if (isLoopback(config.host)) { + const guard = localOnly({ allowedHosts: options.allowedHosts }) + top.use('/__laqi', guard) + top.use('/__laqi/*', guard) + top.route('/', createEditorApp()) + top.route('/__laqi', createControlPlaneApp(controlPlaneRuntime, { guard: { allowedHosts } })) +} +``` + +Error envelope: every refusal is JSON `{ error: 'laqi-control-plane', code, message }` +— the existing envelope plus a machine-readable `code`, which the panel can show +verbatim. (The panel never sees these in normal use; they exist for curl users.) + +### Migration and breaking changes (npm users) + +| Before | After | Who notices | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `curl -X PUT -d '{"scenario":"x"}' .../__laqi/api/state` works | 415: curl's `-d` default is `application/x-www-form-urlencoded`; add `-H 'Content-Type: application/json'` | Scripts against the documented API. The README examples must be updated to carry the header. | +| Panel reachable through a reverse proxy / Codespaces URL | 403 naming the host and the flag; `--allowed-host .app.github.dev` restores it | Codespaces, Gitpod, any proxy that preserves the public `Host`. Whether Codespaces preserves `Host` or rewrites it to `localhost` is **unverified**; if it rewrites, reads work without the flag and writes need the proxy to pass `Origin` consistently. | +| Browser code on `localhost:3000` writing to `/__laqi/api/*` | 403 | Undocumented use; see Open question 1. | +| `dev:hmr` proxy | must set `changeOrigin: true` and strip `Origin` (see boundaries) | Contributors only. | + +Nothing changes for the panel, for curl reads, for MCP, or for mock traffic. + +### Tests + +All in `packages/server/src/local-guard.test.ts` unless stated. `app.request(path, init)` +with explicit headers; the runtime is the existing `makeRuntime()` stub with +`vi.fn()` on writes so "never calls the handler" is asserted. + +| Test name | Input | Expected | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `rejects a GET whose Host is a public name (DNS rebinding)` | `GET /api/status`, `Host: evil.example:8000` | 403, `code: 'host-not-allowed'`, body does not contain `share` | +| `rejects the SSE stream on a public Host` | `GET /events`, `Host: evil.example:8000` | 403 JSON, `content-type` is not `text/event-stream`, `subscribe` never called | +| `admits every loopback spelling of Host` | `Host` ∈ {`localhost:8000`, `LOCALHOST`, `localhost`, `127.0.0.1:8000`, `127.1.2.3:1`, `[::1]:8000`, `[0:0:0:0:0:0:0:1]:8000`, `app.localhost:8000`} | 200 for each | +| `rejects look-alike hosts` | `Host` ∈ {`localhost.evil.example`, `127.0.0.1.evil.example`, `evil.example`, `[::2]:8000`, `0.0.0.0:8000`, `10.0.0.5:8000`} | 403 each | +| `rejects a missing or malformed Host` | no `Host`; `Host: a b`; `Host: user@localhost`; `Host: localhost/path` | 403 each | +| `admits a host from --allowed-host, exact and dot-suffixed` | options `allowedHosts: ['.app.github.dev', 'laqi.internal']`; `Host: x-8000.app.github.dev`, `Host: app.github.dev`, `Host: laqi.internal:9000` | 200 each; `Host: notlaqi.internal` → 403 | +| `rejects an Origin on any other port, on GET and on PUT` | `Host: localhost:8000`, `Origin: http://localhost:3000`; both `GET /api/state` and `PUT /api/state` | 403 `origin-not-allowed`, `setState` not called | +| `rejects Origin null` | `PUT /api/state`, `Origin: null` | 403 | +| `admits the exact served origin, http and https` | `Host: localhost:8000` + `Origin: http://localhost:8000`; `Host: x.app.github.dev` (allowed) + `Origin: https://x.app.github.dev` | 200 each | +| `rejects a write whose Sec-Fetch-Site is same-site or cross-site, even with no Origin` | `DELETE /api/endpoints/GET%20%2Fusers`, `Sec-Fetch-Site: same-site`; then `cross-site` | 403 each, `deleteEndpoint` not called | +| `admits a write with Sec-Fetch-Site same-origin or none` | `PUT /api/state` valid body, `Sec-Fetch-Site: same-origin`; then `none` | 200 each | +| `admits a top-level navigation from another site` | `GET /` on the editor app, `Sec-Fetch-Site: cross-site`, `Sec-Fetch-Mode: navigate`, no Origin | 200 `text/html` (apps/cli `editor-assets.test.ts`) | +| `admits a write with no Origin and no Sec-Fetch-Site (curl)` | `PUT /api/state`, `Content-Type: application/json`, valid body | 200 | +| `refuses a body that is not application/json with 415, before parsing it` | `POST /api/endpoints`, `Content-Type: text/plain`, body is valid JSON | 415, `createEndpoint` not called | +| `accepts application/json with parameters, any case` | `Content-Type: Application/JSON; charset=utf-8` | 201 | +| `a DELETE with no body needs no Content-Type` | `DELETE /api/endpoints/:id`, no body, no Content-Type | 204 | +| `refuses a declared Content-Length over the cap without reading it` | `Content-Length: 8388609`, body stream that throws if read | 413, stream never read | +| `refuses an undeclared body that grows over the cap` | chunked body of 8 MiB + 1 byte | 413, handler not called | +| `a refused request never reaches the catch-all and emits nothing` | `GET /nope`, `Host: evil.example` | 403 (not the 404 envelope) | +| `PATCH is a write` | `PATCH /api/state`, `Origin: http://localhost:3000` | 403 | +| apps/cli `serve.test.ts`: `the panel and the API refuse a rebound Host end to end` | real listener; `fetch(url, { headers: { Host: 'evil.example' } })` on `/__laqi`, `/__laqi/api/status`, `/__laqi/events` | 403 each; `/users` (mock) still 200 with the same Host | +| apps/cli `serve.test.ts`: `--allowed-host reaches the listener` | `startServer({ allowedHosts: ['laqi.test'] })`, `Host: laqi.test` | 200 on `/__laqi/api/status` | +| apps/cli `index.test.ts`: `--allowed-host is repeatable and printed` | argv `--allowed-host a.test --allowed-host .b.test` | `startServer` receives both; start screen has a `hosts` row | + +The existing tests at `control-plane-app.test.ts:483-499` ("allows a POST with a +legitimate local Origin (http://localhost:3000)") and `:565-583` ("allows +`http://[::1]:5173`") encode the behaviour D2 removes. They **must** be inverted, not +deleted: same inputs, expected 403. + +## F2 — Share token disclosure + +### Current behaviour on main + +- `apps/cli/src/serve.ts:173-183` — `getStatus()` returns `share.token` with the + comment "The token travels to the panel on purpose: it's local-only". +- `packages/server/src/control-plane-app.ts:432` — `GET /api/status` returns it to + any admitted caller. +- `apps/cli/src/index.ts:387-397` — the terminal prints the token and a ready + `curl`. +- `packages/editor/src/components/ShareBand.tsx:11-13, 35` reads `share.token` to + build the curl, mask it (`'•'.repeat(token.length)`) and reveal it; + `packages/editor/src/types.ts:39-45` types it. +- `serve.test.ts:354-367` asserts `status.share.token === 'testtoken'`. + +### Threat + +With F1 in place the rebinding read is closed. The token stays in a `GET` body +nevertheless, which means: any future admission bug, any same-origin XSS in the panel +(the CSP of F3 reduces but does not eliminate), any browser extension with host +permissions on `localhost`, and any screenshot of the Network tab exposes the one +secret that opens the tunnel from the internet. A secret should not be a side effect +of a status poll the panel issues every few seconds. + +### Designed behaviour + +- `getStatus().share` **must not** contain `token`. It carries + `{ url: string | null, hasToken: boolean, exposed: string, closedReason?: string }` + (`closedReason` is F9's). +- A new route `POST /__laqi/api/share/token` **must** return `200 { token }` with + `Cache-Control: no-store` when sharing is on and a token exists; `404 { code: 'not-sharing' }` + when `--share` is off; `404 { code: 'no-token' }` with `--public`. It takes no body. + Being a `POST`, it is covered by R2 and R3 (any cross-origin page sends `Origin` + and `Sec-Fetch-Site: cross-site`; both are rejected). +- The panel **must** call it only on `Reveal token` and `Copy curl`, never on load, + and **must** drop the value when the band is hidden again. The mask is a fixed + `lq_••••••••••••` as F7's flow spec draws it, not the token's length. +- The terminal stays as it is (`index.ts:387-397`): it is the place ADR-0007 item 5 + names, and it is where a developer who is not on the panel finds the token. +- `ControlPlaneRuntime` gains `revealShareToken: () => { ok: true; token: string } | { ok: false; code: 'not-sharing' | 'no-token' }`. + +### Interfaces (illustrative) + +```ts +// ControlPlaneRuntime.getStatus — illustrative +share?: { url: string | null; hasToken: boolean; exposed: string; closedReason?: string } | null + +// packages/editor/src/api.ts — illustrative +revealShareToken: () => request<{ token: string }>('/api/share/token', { method: 'POST' }), +``` + +### Migration + +`share.token` disappears from `/api/status`. Any script that read it must move to +`POST /__laqi/api/share/token` or the terminal. The README's share section does not +promise the field, so this is a change to an undocumented shape; the CHANGELOG entry +names it anyway. + +### Tests + +| Test name | Input | Expected | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| server `GET /api/status never carries the share token` | runtime with `share: { url, hasToken: true, exposed }` | body has `share.hasToken === true` and no `token` key; `JSON.stringify(body)` does not contain the token value | +| server `POST /api/share/token returns the token with no-store` | `revealShareToken` → ok | 200, `{ token }`, `cache-control: no-store` | +| server `POST /api/share/token is 404 when not sharing, and with --public` | `{ ok: false, code: 'not-sharing' }`; `{ ok: false, code: 'no-token' }` | 404 each with the code | +| server `a cross-origin page cannot reveal the token` | `POST /api/share/token`, `Origin: http://localhost:3000` | 403 (F1), `revealShareToken` not called | +| cli `serve.test.ts` (replaces `:354-367`): `reports the share state without the token` | `startServer` with `share: shared()` | `status.share` is `{ url: null, hasToken: true, exposed: ... }`; `POST /__laqi/api/share/token` → `testtoken` | +| cli `the share token is not in any GET under /__laqi` | fetch `/__laqi/api/status`, `/__laqi/api/endpoints`, `/__laqi/api/state`, `/__laqi/api/scenarios`, `/__laqi/api/schema/capabilities` | none of the bodies contains `testtoken` | +| editor `ShareBand.test.tsx`: `reveals the token only on request` | render with `hasToken: true`; click Reveal | `revealShareToken` called once; token rendered; before the click the mask is `lq_••••••••••••` and the token is not in the DOM | + +## F3 — Panel and API response headers + +### Current behaviour on main + +`apps/cli/src/editor-assets.ts:75` sets `Content-Type` and nothing else on +`index.html`, assets and `favicon.svg`; `:59` returns the "not built" page with no +headers. Control-plane JSON responses carry only what `c.json` sets. + +The built panel (`packages/editor`) is a Vite SPA (`ADR-0011`): `index.html` has one +`