|
| 1 | +# ADR-272: WebSocket authentication tickets |
| 2 | + |
| 3 | +- **Status**: accepted |
| 4 | +- **Date**: 2026-07-22 |
| 5 | +- **Deciders**: RuView maintainers |
| 6 | +- **Tags**: auth, websocket, security, sensing-server |
| 7 | +- **Related**: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060 |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +`bearer_auth` gates `/api/v1/*`. WebSocket upgrade endpoints were exempt, for a |
| 12 | +real reason: a browser's `WebSocket` constructor cannot attach an |
| 13 | +`Authorization` header to the handshake, so a gated socket is simply |
| 14 | +unreachable from page JavaScript. `/ws/sensing` and `/ws/introspection` sat |
| 15 | +outside `PROTECTED_PREFIX` entirely; `/api/v1/stream/pose` was added to an |
| 16 | +explicit `EXEMPT_PATHS` list by PR #1313. |
| 17 | + |
| 18 | +The reasoning was sound. The consequence was not, and it was measured rather |
| 19 | +than argued. On a server with `RUVIEW_API_TOKEN` set — an operator who believes |
| 20 | +authentication is ON — a real WebSocket handshake carrying **no credential at |
| 21 | +all**: |
| 22 | + |
| 23 | +``` |
| 24 | +/ws/sensing -> 101 Switching Protocols |
| 25 | +/ws/introspection -> 101 Switching Protocols |
| 26 | +/api/v1/stream/pose -> 101 Switching Protocols |
| 27 | +/api/v1/models -> 401 Unauthorized (control) |
| 28 | +``` |
| 29 | + |
| 30 | +**The control plane was locked and the data plane was open.** `/ws/sensing` |
| 31 | +carries the live sensing output — presence, pose, breathing and heart rate. |
| 32 | +`/ws/introspection` exposes internal pipeline state. For the ADR-055 desktop |
| 33 | +topology (server bundled in the app, loopback only) that is bounded. For the |
| 34 | +LAN/hub deployment RuView also supports, anyone who can reach the port can |
| 35 | +watch the sensor. |
| 36 | + |
| 37 | +ADR-271 sharpened the contrast rather than causing it: the REST surface is now |
| 38 | +genuinely strong — offline-verified Cognitum tokens, scope-separated |
| 39 | +destructive routes — which makes an ungated data plane the obvious way in. |
| 40 | + |
| 41 | +*Precision about the evidence:* the handshake completing was verified. A |
| 42 | +payload frame was not captured in that window, so the finding is "the |
| 43 | +connection is established without a credential", not "data was read". |
| 44 | + |
| 45 | +## Decision |
| 46 | + |
| 47 | +Gate every WebSocket upgrade. Accept **either** of two credentials, chosen to |
| 48 | +match what each kind of client can actually do. |
| 49 | + |
| 50 | +### 1. Native clients send a bearer on the upgrade |
| 51 | + |
| 52 | +The Python client, the Rust CLI and the TypeScript MCP client are not browsers |
| 53 | +and have never been subject to the header limitation. They **can** send a normal |
| 54 | +`Authorization: Bearer` on the handshake, so the server accepts one there; |
| 55 | +routing them through a ticket would add a round-trip and a second credential |
| 56 | +path for no benefit. |
| 57 | + |
| 58 | +> **Correction, 2026-07-23.** This section previously stated that those clients |
| 59 | +> **do** send a bearer. The published Python client does not: |
| 60 | +> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url, |
| 61 | +> ping_interval, ping_timeout, max_size)` and passes no headers at all — the |
| 62 | +> file contains zero occurrences of `extra_headers` or `Authorization`. So every |
| 63 | +> `wifi-densepose[client]` consumer **401s the moment an operator enables |
| 64 | +> auth**, and this ADR told them they would be fine. |
| 65 | +> |
| 66 | +> The server side of the decision stands — a bearer on the upgrade is accepted, |
| 67 | +> and that is the right contract for a non-browser client. What is missing is |
| 68 | +> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only |
| 69 | +> remedy available to those users is |
| 70 | +> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR |
| 71 | +> exists to close — so it is a migration aid with a deadline, not an answer. |
| 72 | +
|
| 73 | +### 2. Browsers exchange their credential for a single-use ticket |
| 74 | + |
| 75 | +`POST /api/v1/ws-ticket` is an ordinary authenticated request — where headers |
| 76 | +*do* work — and returns an opaque ticket the page appends as |
| 77 | +`?ticket=<value>` on the socket URL. |
| 78 | + |
| 79 | +**A credential in a URL is normally a mistake.** URLs reach access logs, |
| 80 | +`Referer` headers and browser history. Three properties bound this one, and all |
| 81 | +three are load-bearing: |
| 82 | + |
| 83 | +| Property | Why it matters | |
| 84 | +|---|---| |
| 85 | +| **Single use** — consumed on the first upgrade attempt, valid or not | A ticket found in a log is already spent | |
| 86 | +| **~30 second TTL** | Long enough to open a socket; not long enough to harvest | |
| 87 | +| **Not the credential** — authorizes one WebSocket | Cannot be replayed against `/api/v1/*`, cannot be refreshed, carries no reusable identity | |
| 88 | + |
| 89 | +The long-lived bearer token is still never placed in a URL. |
| 90 | + |
| 91 | +A ticket **inherits the issuing principal's scopes**, so a `sensing:read` |
| 92 | +session cannot mint one that outranks itself, and a ticket from a token without |
| 93 | +`sensing:read` is refused at the upgrade. |
| 94 | + |
| 95 | +### 3. WebSocket paths are matched by **prefix**, not by an allowlist |
| 96 | + |
| 97 | +Anything under `/ws/` is treated as an upgrade path, plus the one endpoint that |
| 98 | +lives outside it (`/api/v1/stream/pose`). |
| 99 | + |
| 100 | +This is the most important detail in the ADR. An allowlist means every |
| 101 | +WebSocket route added later is ungated until someone remembers to extend it — |
| 102 | +the same bug, reintroduced on a delay. It is not hypothetical: |
| 103 | +`/ws/train/progress` (ADR-186, arriving with PR #1387) is already referenced by |
| 104 | +`ui/services/training.service.js` and would have shipped unauthenticated under |
| 105 | +an allowlist. Prefix matching gates it on arrival. |
| 106 | + |
| 107 | +New WebSocket routes should live under `/ws/` and inherit gating for free. |
| 108 | + |
| 109 | +### 4. A migration escape hatch, deliberately uncomfortable |
| 110 | + |
| 111 | +`RUVIEW_WS_LEGACY_UNAUTHENTICATED=1` restores the previous behaviour. Gating |
| 112 | +these paths **breaks a browser UI that has not yet been updated to fetch a |
| 113 | +ticket**, and not every deployment can update server and UI in lockstep. |
| 114 | + |
| 115 | +It is a migration aid, not a supported configuration: |
| 116 | + |
| 117 | +- It logs a warning on every boot naming the actual exposure — "the live |
| 118 | + sensing stream — presence, pose and vital signs — is readable by anyone who |
| 119 | + can reach this port" — rather than something an operator can skim past. |
| 120 | +- Its blast radius is exactly the WebSocket paths. A test pins that it does not |
| 121 | + weaken `/api/v1/*`. |
| 122 | +- It is read **once at construction**, so changing the environment cannot |
| 123 | + silently open the paths on a running server. |
| 124 | + |
| 125 | +The alternative — a clean break with no hatch — was considered and rejected as |
| 126 | +sequencing, not principle: a hard break tempts an operator into turning auth off |
| 127 | +entirely, which is strictly worse than a narrow, loudly-announced exception. |
| 128 | +The hatch should be removed once the shipped UI fetches tickets. |
| 129 | + |
| 130 | +### 5. Deployments with auth off are unchanged |
| 131 | + |
| 132 | +No credential configured ⇒ the middleware is the same no-op it has always been. |
| 133 | +Pinned by a test. |
| 134 | + |
| 135 | +## Consequences |
| 136 | + |
| 137 | +- The measured hole is closed: all three paths now return `401` to a |
| 138 | + credential-less handshake, while a bearer or a valid ticket returns `101`. |
| 139 | +- Browser UIs need updating. Shipped in the same change for |
| 140 | + `sensing.service.js`, `websocket-client.js` and `observatory/js/main.js` via |
| 141 | + a shared `withWsTicket()` helper; a ticket is minted per connection attempt |
| 142 | + and never cached, because it is single-use and short-lived. |
| 143 | +- A UI running against a server that predates this ADR still works: the helper |
| 144 | + treats `404` from `/api/v1/ws-ticket` as "no ticket needed". |
| 145 | +- One more round-trip before a browser opens a socket. Negligible against a |
| 146 | + stream that then runs for minutes. |
| 147 | +- Tickets live in memory, capped at 512 outstanding and self-healing as they |
| 148 | + expire, so an authenticated but misbehaving caller cannot grow the store |
| 149 | + without bound. In-memory is correct rather than convenient: a ticket |
| 150 | + surviving a restart would outlive the server that vouched for it. |
| 151 | + |
| 152 | +## Supersedes |
| 153 | + |
| 154 | +PR #1313's `enabled_exempts_pose_stream_websocket`, which asserted the |
| 155 | +exemption. Its premise about browsers was correct and is preserved here; its |
| 156 | +conclusion is replaced. The test was renamed and inverted rather than deleted, |
| 157 | +with the history in its doc comment, and the half that still matters — the |
| 158 | +WebSocket rule must not leak to other `/api/v1/*` paths — is kept. |
| 159 | + |
| 160 | +## Deliberately not done |
| 161 | + |
| 162 | +- **`/health*` stays ungated.** Orchestrator probes hit it anonymously, and |
| 163 | + that is the point of a liveness endpoint. `/health/metrics` is included in |
| 164 | + that exemption; if metrics ever carry occupancy-derived values this should be |
| 165 | + revisited, because that would make them sensing data wearing an ops label. |
| 166 | +- **`/ui/*` stays ungated.** It is static assets; the data behind them is |
| 167 | + gated. |
| 168 | +- **No revocation of an issued ticket.** It expires in seconds and is |
| 169 | + single-use; a revocation path would be more machinery than the exposure |
| 170 | + justifies. |
| 171 | +- **No ticket for native clients.** They can send a header, so they should. |
| 172 | + |
| 173 | +## Implementation |
| 174 | + |
| 175 | +`v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs` (store), |
| 176 | +`src/bearer_auth.rs` (gating), `src/main.rs` (`POST /api/v1/ws-ticket`), |
| 177 | +`ui/services/ws-ticket.js` plus the three call sites. |
| 178 | + |
| 179 | +Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use |
| 180 | +enforcement, replay refusal, expiry refusal *and* pruning, 256-bit |
| 181 | +unpredictability, cap enforcement and self-healing, and `?myticket=x` not being |
| 182 | +read as `?ticket=x`. Gating coverage includes every known WS path refusing an |
| 183 | +unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being |
| 184 | +useless against REST, the escape hatch working *and* not weakening REST, and |
| 185 | +auth-off behaviour unchanged. |
0 commit comments