# 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 `--allow-origin <url>` exactly matches
the target origin. This keeps the blast radius at the page's own 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 without `SO_REUSEADDR`; if a fixed port is taken 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`.

## 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.
- 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), and `--allow-origin` stays a single global value; a per-origin
  allowlist remains possible future work. The remaining limit (stdin piping) can
  still be added without revisiting this decision.

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