# ADR-0036: Confused-Deputy Browser Bridge with Dual-Plane Default-Closed Authentication

## Status

✅ Accepted (2026-05-30)

## Context

When investigating internal services (Grafana/Loki, internal dashboards, SSO-gated
admin panels), the authenticated session lives in the operator's browser. The
session is established through SSO/OAuth flows, short-lived cookies, and CSRF
tokens that are impractical to replicate from a standalone HTTP client. An
operator who wants to script against such a service — pull a window of Loki logs,
page through an internal API — has no good programmatic path: the credentials are
deliberately hard to extract, and extracting them (copying cookies, scraping
bearer tokens) is exactly the exfiltration the service's controls are designed to
prevent.

[#902](https://github.com/rust-works/omni-dev/issues/902) asks for a bridge that
lets `omni-dev` (or a local tool the operator controls) issue requests **inside
the browser's authenticated context** without ever seeing the credentials. A JS
snippet pasted into the page's DevTools console opens a WebSocket back to a local
`omni-dev` process; that process exposes a local HTTP control plane the operator
drives; requests flow control-plane → WebSocket → in-page `fetch(..., {credentials:
'include'})` → response back.

This is, by construction, a **confused deputy**: the bridge borrows the browser's
authority on behalf of whoever can talk to it. That is the feature, not a bug —
which means the security model is the load-bearing part of the design, not an
add-on. The threat is not "can someone off-host reach this" (a loopback bind
handles that); it is:

1. **Other local users/processes** on a shared host reaching the control plane.
2. **Any web page the operator visits** while the bridge runs — a local HTTP
   server is reachable by every origin's JavaScript, and is a classic target for
   CSRF and DNS-rebinding attacks.
3. **The pasted snippet itself** being trusted to scope requests — it runs in a
   hostile page and must not be the security boundary.

A localhost bind is *necessary but not sufficient*: it stops (off-host) but
neither (1), (2), nor (3).

## Decision

### Two authenticated planes, both default-closed

The bridge runs a WebSocket plane (the browser connects to it) and an HTTP
control plane (the operator drives it), implemented in
[src/browser/bridge.rs](../../src/browser/bridge.rs) with the guard primitives in
[src/browser/auth.rs](../../src/browser/auth.rs). The trust boundary, stated once:
**a request is trusted only if it presents the session token AND is not a
cross-origin browser request; everything else is denied.**

### Session token — generated, never from argv

A 256-bit URL-safe token is generated at startup (`auth::generate_token`) and
printed to stdout alongside the snippet. It is **never** accepted as a CLI
argument — argv is world-readable via `ps`/`/proc`, so a token-on-the-command-line
would defeat threat (1). It may optionally be supplied via `OMNI_BRIDGE_TOKEN` or
a `--token-file` that must be `0600` (`auth::resolve_token`). Token comparisons are
constant-time (`auth::constant_time_eq`) to avoid timing leaks.

- **Control plane**: every request must carry `Authorization: Bearer <token>`.
- **WebSocket plane**: the browser presents the token via the WS subprotocol
  (`new WebSocket(url, [token])` → `Sec-WebSocket-Protocol`); the upgrade is
  rejected without it (`auth::ws_subprotocol_token`). An unauthenticated peer can
  neither connect nor evict an authenticated browser — connections live in a
  `conn_id`-keyed registry, so each is cleared only by its own disconnect and a
  new connection never displaces an existing one (see the #908 note below).

### Control-plane anti-CSRF / anti-DNS-rebinding

Because the control plane is reachable by any page the operator visits, the
`guard` middleware enforces *all* of the following, and emits **no** CORS allow
headers and refuses `OPTIONS`:

| Control | Defeats | Implementation |
|---------|---------|----------------|
| `Host` allowlist (`localhost:<port>` / `127.0.0.1:<port>` / `[::1]:<port>`) | DNS rebinding | `auth::host_allowed` |
| Reject any `Origin` or `Sec-Fetch-Site: cross-site`/`same-site` | browser-originated requests | `auth::is_browser_originated` |
| Require `X-Omni-Bridge: 1` custom header | simple-request CSRF (forces a preflight the server refuses) | `auth::has_bridge_header` |
| Never answer `OPTIONS`; never send CORS allow headers | preflight-based CSRF | `guard` early return |

A legitimate CLI client sends no `Origin`, no `Sec-Fetch-Site`, and *can* set a
custom header; a web page is the opposite on all three.

### Server-side outbound scope — the snippet is not trusted

Scope is enforced **before** a command leaves the server
(`auth::validate_outbound_url`), not in the snippet. Relative URLs (resolved
against the page origin) are allowed by default; absolute / cross-origin /
protocol-relative URLs are rejected unless an `--allow-origin` grant matches the
target origin. The grant is a **per-connecting-origin allowlist**
(`auth::OriginAllowlist`): `--allow-origin` is repeatable, each value a bare
`ORIGIN` or a `CONNECT=OUTBOUND` mapping, and the outbound check keys on the
connecting origin of the tab a request is **routed to** — so each tab carries
only its own outbound grant and cannot borrow another tab's. This keeps the
blast radius at the page's own (or explicitly granted) origin even if the snippet
is tampered with.

### Robustness limits and fail-closed binding

Per-request timeout (`--request-timeout`, default 30s → `504` and drop from the
pending map), max response body size (`--max-body-bytes`), and a concurrency
semaphore (`--max-concurrent`) bound memory and prevent local DoS. Both listeners
bind with `SO_REUSEADDR` so a restart can rebind the fixed ports immediately even
when a just-closed connection still holds them in `TIME_WAIT` (#990); `SO_REUSEADDR`
(unlike `SO_REUSEPORT`, which is not used) does not permit a second live listener on
the same address+port, so if a fixed port is held by a live listener the bind still
fails with `EADDRINUSE` and the process exits rather than racing a possible
squatter. Passing `0` for either port binds an OS-assigned free
port, read back from the listener and reflected in the printed snippet and the
`Host` allowlist.

Caller-supplied header names/values are rejected if they contain CR/LF/NUL
(`auth::header_is_safe`), and the decoded request path is normalised and checked
for traversal **before** the `/__bridge/` routing/auth split
(`auth::normalize_request_path`) so a `..%2f`-style segment cannot bypass the
prefix.

### Accepted risks / relied-upon controls

- `ws://localhost` is plaintext; loopback traffic is not sniffable without root —
  **accepted**.
- The browser forbids JS from setting `Cookie` or reading `Set-Cookie`, so the
  snippet cannot smuggle those out of the page — a **relied-upon browser control**.
- No request URLs, query expressions, headers, or bodies are logged at the default
  level (they carry authenticated/sensitive data); only connection lifecycle and
  rejection reasons are logged at `info`/`warn`.
- A foreground `serve` prints the session token to stdout (the operator must
  paste it into DevTools); capture of that output in terminal scrollback, tmux
  logs, or CI transcripts is a residual exposure of a loopback-only,
  process-lifetime credential — **accepted**; restart to rotate (#1148).

## Consequences

- The bridge is genuinely usable for its purpose (scripting against SSO-gated
  internal services) while every plane is authenticated and default-closed; the
  confused-deputy authority is bounded to the page origin and gated on a secret
  the attacker in threats (1)–(3) does not hold.
- Three independent layers must each pass for a control-plane request to succeed
  (token, non-browser-origin, custom header). This is deliberate
  defence-in-depth: any one is individually bypassable in some threat model, but
  not all three together.
- The snippet being untrusted means scope changes (e.g. allowing a second origin)
  are server-side flags, not snippet edits — the operator cannot accidentally
  widen scope by pasting a modified snippet.
- New dependencies enter the tree: `axum` (control plane, on transitive
  `hyper`/`tower`), `tokio-tungstenite` (WS plane), and `rand` (token). `axum` was
  chosen over hand-rolling `hyper` for the ergonomics of its extractor/middleware
  model, which keeps the guard logic in one auditable `from_fn` layer.
- v1 was deliberately limited (see #902 "Out of scope"): no streaming/chunked
  bodies, text bodies only, a single browser tab, and no stdin/stdout piping.
  Binary response bodies have since been added (#906): the snippet base64-encodes
  non-text bodies and tags the frame with `encoding: "base64"`, the proxy decodes
  before writing the HTTP response, and `--max-body-bytes` is accounted against
  the decoded size. Binary **request** bodies were later added symmetrically
  (#1123): the CLI `--body-file` (or a raw caller setting `encoding: "base64"` on
  the request) base64-encodes the body, the snippet decodes it to a byte array
  before `fetch()`, and the new optional `encoding` field on `ControlRequest`/
  `Command` uses the same `skip_serializing_if` contract so an absent value keeps
  the wire byte-identical for older clients. This does not revisit the trust
  boundary — it only widens the body representation.
- Streaming/chunked response bodies have since been added (#907) as a
  caller-opt-in protocol extension that does **not** revisit the trust boundary:
  scope, token, and origin checks run on the streamed request exactly as for a
  buffered one. The single `oneshot` per id becomes a channel; the snippet reads
  `response.body.getReader()` and emits a head frame, base64 chunk frames, and a
  terminator; the proxy streams raw bytes while `POST /__bridge/request` streams
  NDJSON. For streams, `--request-timeout` is reinterpreted as an inter-chunk
  idle timeout and `--max-body-bytes` as a cumulative ceiling; exceeding either
  (or a control-plane consumer disconnecting) sends the browser a `cancel` frame
  so it stops its in-page reader. There is **no** socket-level backpressure to
  the browser's reader — memory is bounded by the cumulative cap rather than
  flow control, an accepted limitation.
- Multiple concurrent browser tabs with request routing have since been added
  (#908) without revisiting the trust boundary. The single `Option<WsConn>`
  becomes a `conn_id`-keyed registry; each connection still authenticates
  independently via the token subprotocol, and because every connection lives
  under its own key, a new authenticated tab can never displace an existing one —
  the non-eviction guarantee now holds **per connection**. A request selects its
  target tab with an `X-Omni-Bridge-Target` header (or `target` body field) by
  connection id or unique origin; with several tabs connected and no target the
  request is rejected rather than routed ambiguously. Routing is strictly to one
  tab (no fan-out). The remaining limit (stdin piping) can still be added without
  revisiting this decision.
- A per-request `--allow-origin` override has since been added (#918) without
  loosening the trust boundary. `serve --allow-origin` feeds **two** checks at
  different times — the WebSocket-upgrade gate (the tab's `Origin`, at connection
  time) and `validate_outbound_url` per request (the target URL's origin) — so
  widening the grant to permit a cross-origin target would also relax the
  connection-time gate and could reject the page's own tab. The new
  `request --allow-origin` (and `allow_origin` on `POST /__bridge/request`)
  reaches **only** `validate_outbound_url` for that one request and never the
  upgrade gate; it is explicit and per-request, never a default. Blast radius
  stays bounded by the browser's own CORS (the response body is readable only if
  the target sends permissive CORS).
- The `serve --allow-origin` grant has since become a **per-connecting-origin
  allowlist** (#1185) without moving the trust boundary. It was a single global
  value applied to every connection; it is now repeatable and keyed on the
  connecting tab's origin (`auth::OriginAllowlist`), so a tab is granted only the
  outbound scope declared for *its* origin — a Grafana tab and a Facebook tab no
  longer share the widest scope either needs. The two checks are unchanged in
  kind: the upgrade gate now admits any origin that is a configured **key** (open
  when unconfigured), and the per-request outbound check reads the outbound set
  bound to the **routed** tab's connecting origin. Default-closed is preserved:
  with no `--allow-origin`, the gate stays open (the token is the gate) and
  outbound is relative-only. The per-request override still wins for one request.
- A per-request `--credentials <include|omit|same-origin>` override has since
  been added (#920), threaded through `ControlRequest`/`Command` to the snippet's
  `fetch(..., {credentials})`. The default stays `include` (the v1 behaviour), so
  it neither widens nor narrows the trust boundary by default; `omit` exists to
  read wildcard-CORS cross-origin assets the browser refuses to expose to a
  credentialed request, and is paired with `--allow-origin`.
- The bridge can now be **hosted inside the extensible omni-dev daemon**
  ([ADR-0039](adr-0039.md), #987) instead of only as a standalone `serve`
  process. This does **not** revisit the trust boundary: both planes stay loopback
  TCP with bearer auth on each, the control-plane anti-CSRF/anti-rebinding guards
  and server-side `validate_outbound_url` scope are unchanged, and the default-closed
  posture holds. The daemon owns only lifecycle, single-instance supervision,
  status aggregation, and the macOS menu bar — it does **not** proxy browser
  traffic, and its own operator/tray control channel is a separate Unix-domain
  socket (`0600`, gated by filesystem permission), not a network surface. The one
  externally observable delta: the generated session token, previously visible
  only on the foreground `serve` stdout, is now also written to a `0600`
  `bridge.token` file under the runtime directory so the thin clients can discover
  it without a foreground process — the token is still generated (never from
  argv), still compared in constant time, and the file is still required to be
  `0600`. `serve` remains supported and unchanged.

See [docs/browser-bridge.md](../browser-bridge.md) for the operator-facing guide.
