fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -144,8 +144,11 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
||||
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
|
||||
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
|
||||
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
|
||||
from inside it is blocked as **mixed content**, a long-standing WebKit limitation
|
||||
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the
|
||||
from inside it is blocked as **mixed content**. (Nearest upstream ticket:
|
||||
[bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is
|
||||
specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000`
|
||||
would stay mixed content even if it were fixed, so the plugin route below is the right
|
||||
architecture for a remote booth regardless, not a stopgap.) `connect-src` in the
|
||||
CSP does **not** override this — it's a different browser security layer entirely, so the request
|
||||
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
|
||||
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
|
||||
@@ -167,7 +170,9 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
||||
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
|
||||
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
|
||||
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
|
||||
browser `WebSocket` would. The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
|
||||
browser `WebSocket` would. (The **HTTP** plugin, by contrast, *does* attach that Origin itself —
|
||||
`tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS
|
||||
path needs the explicit header.) The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
|
||||
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
|
||||
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
|
||||
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
|
||||
@@ -332,8 +337,9 @@ runtime-persisted** value.
|
||||
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
|
||||
`connect-src`'s jurisdiction entirely. The real access boundary moved to
|
||||
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
|
||||
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri
|
||||
scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket:
|
||||
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
|
||||
URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*`
|
||||
covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket:
|
||||
default` already had no scope restriction. Net effect: **the app can now reach any host the
|
||||
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
|
||||
instead of build-directed.
|
||||
@@ -351,10 +357,12 @@ runtime-persisted** value.
|
||||
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
|
||||
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
|
||||
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
|
||||
can never see it. This is an open, unresolved upstream Tauri bug
|
||||
([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045),
|
||||
[#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by
|
||||
changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
|
||||
can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
|
||||
(open — asks for exactly this jar→webview sync) and
|
||||
[#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
|
||||
something fixable on our side by changing how/when we read the cookie. The reqwest jar itself
|
||||
IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart
|
||||
just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
|
||||
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
|
||||
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
|
||||
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
|
||||
@@ -369,3 +377,46 @@ runtime-persisted** value.
|
||||
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
|
||||
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
|
||||
server verification are both completely unchanged.
|
||||
|
||||
### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6)
|
||||
|
||||
A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first**
|
||||
of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring
|
||||
at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed
|
||||
a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected.
|
||||
|
||||
- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket
|
||||
plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no
|
||||
cookie jar at all** (its source has no cookie handling); the cookie lives in
|
||||
`tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either.
|
||||
Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop
|
||||
client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets
|
||||
a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents
|
||||
it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts
|
||||
ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both.
|
||||
A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a
|
||||
browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending
|
||||
it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS,
|
||||
which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case
|
||||
handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin
|
||||
→ 403; cookie path unchanged.
|
||||
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
|
||||
from `document.cookie` (null on desktop — the same jar split as above), so every
|
||||
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
|
||||
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
|
||||
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
|
||||
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
|
||||
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
|
||||
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
|
||||
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
|
||||
debug/trace.
|
||||
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
|
||||
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
|
||||
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
|
||||
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
|
||||
and that value.
|
||||
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
|
||||
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
|
||||
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
|
||||
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
|
||||
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).
|
||||
|
||||
Reference in New Issue
Block a user