From 8fa66c9911e456a0fd69f8219e1a314f5924db90 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 12:09:30 +0200 Subject: [PATCH] fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/desktop/README.md | 21 +++++++ apps/server/src/routes/routes.test.ts | 2 +- apps/server/src/routes/ws.ts | 83 +++++++++++++++++++++++++-- apps/server/src/server.ts | 5 +- apps/web/src/api.ts | 36 ++++++------ apps/web/src/lib/backend-config.ts | 25 ++++---- apps/web/src/lib/desktop-csrf.ts | 25 ++++++++ apps/web/src/lib/logger.ts | 8 ++- apps/web/src/lib/platform-ws.ts | 42 +++++++++++--- wiki/decisions/desktop-shell-tauri.md | 69 +++++++++++++++++++--- wiki/log.md | 19 ++++++ 11 files changed, 279 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/lib/desktop-csrf.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 315b19c..d3db707 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,6 +44,27 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never committed. +## Release gate — run the REAL bundle locally before tagging + +`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle +loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug +found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS +`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie) +depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`" +carries no information about a release. Before pushing a `vX.Y.Z` tag: + +1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and + `tauri://localhost` in `WS_ALLOWED_ORIGINS`). +2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from + `src-tauri/target/release/bundle/appimage/` (WSLg is enough). +3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**. +4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds. +5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF). +6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists + (proves the desktop log channel; historically it was silently 403'd). + +Only then tag. If a release still fails in the field, the gap is in this list — fix the list. + ## Not here (deliberately) Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for diff --git a/apps/server/src/routes/routes.test.ts b/apps/server/src/routes/routes.test.ts index 0793f3d..72807f6 100644 --- a/apps/server/src/routes/routes.test.ts +++ b/apps/server/src/routes/routes.test.ts @@ -30,7 +30,7 @@ describe("health + login", () => { it("GET /health is open", async () => { const res = await app.inject({ method: "GET", url: "/health" }); expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ status: "ok" }); + expect(res.json()).toEqual({ status: "ok", app: "parking-system" }); }); it("login with bad credentials is rejected", async () => { diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index 44f7a9d..e143de8 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -1,7 +1,8 @@ +import { randomBytes } from "node:crypto"; import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; -import { roleHasPermissions } from "../auth.js"; +import { requireAuth, roleHasPermissions } from "../auth.js"; import { deviceEvents, type LaneStatusEvent, @@ -31,11 +32,57 @@ import { getOccupancy } from "../occupancy.js"; // an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly // allowed booth UI origin). Non-browser clients (no Origin) are rejected too. // See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md. +// +// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes +// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket +// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so +// the JWT cookie set at login can never ride on the WS handshake — jwtVerify() +// would 401 every connect (found 2026-09-04: the desktop live feed reconnected +// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead +// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a +// single-use, 30-second random ticket bound to its user, and presents it in an +// `x-ws-ticket` header on the handshake. A browser page cannot set custom +// headers on a WebSocket, so this path is unreachable from a browser and adds +// no CSWSH surface; the Origin allowlist still applies to both paths. /** Permission required to watch the live feed (a read-only stream of ledger + * device status). Any role granted `report:read` may watch. */ const WATCH_PERMISSION = "report:read" as const; +/** Handshake header carrying a desktop WS ticket (see file header). */ +const WS_TICKET_HEADER = "x-ws-ticket"; +/** A ticket is only good for the connect that immediately follows its issue. */ +const WS_TICKET_TTL_MS = 30_000; + +interface WsTicket { + sub: string; + roleId: string; + expiresAt: number; +} + +/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only — + * a server restart invalidates them, which is fine: the client just asks for + * another on its next reconnect. */ +const tickets = new Map(); + +function issueWsTicket(sub: string, roleId: string): string { + const now = Date.now(); + for (const [key, t] of tickets) { + if (t.expiresAt <= now) tickets.delete(key); + } + const ticket = randomBytes(32).toString("hex"); + tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS }); + return ticket; +} + +/** Single-use: the ticket is removed whether or not it turns out to be valid. */ +function consumeWsTicket(ticket: string): WsTicket | null { + const t = tickets.get(ticket); + if (!t) return null; + tickets.delete(ticket); + return t.expiresAt > Date.now() ? t : null; +} + /** * Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is * always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS @@ -80,19 +127,43 @@ export async function wsRoutes( laneStatus: LaneStatus, lanePresence: LanePresence, ): Promise { + // Desktop-only: mint a WS ticket for the signed-in session (see file header). + // Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via + // tauri-plugin-http), it just can't carry the cookie onto the WebSocket. + app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({ + ticket: issueWsTicket(req.user.sub, req.user.roleId), + expiresInMs: WS_TICKET_TTL_MS, + })); + app.get( "/api/ws", { websocket: true, - // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT + - // role. Reject a cross/absent origin before touching the token, so a hijack - // attempt never reaches an authenticated socket. jwtVerify reads the cookie. + // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN + // session (JWT cookie, or a desktop WS ticket) THEN role. Reject a + // cross/absent origin before touching either credential, so a hijack + // attempt never reaches an authenticated socket. preHandler: async (req) => { if (!isAllowedOrigin(req.headers.origin, req.headers.host)) { throw Object.assign(new Error("forbidden origin"), { statusCode: 403 }); } - await req.jwtVerify(); - if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) { + const rawTicket = req.headers[WS_TICKET_HEADER]; + const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket; + let roleId: string; + if (ticket !== undefined) { + const t = consumeWsTicket(ticket); + if (!t) { + throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 }); + } + roleId = t.roleId; + } else { + await req.jwtVerify(); // reads the HttpOnly cookie (browser path) + if (!req.user) { + throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + } + roleId = req.user.roleId; + } + if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e540937..b0043f9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise ({ status: "ok" })); + // Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen + // test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any + // other service that happens to answer on the address the operator typed. + app.get("/health", async () => ({ status: "ok", app: "parking-system" })); // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. await authRoutes(app, db); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9df39d2..ab0bb5f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -5,18 +5,14 @@ // back in the X-CSRF-Token header (double-submit). See // wiki/entities/local-jwt-auth.md. // -// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's -// reqwest, which keeps its OWN cookie jar separate from the webview — -// document.cookie on the tauri://localhost page never sees a cookie set on a -// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The -// cookie itself IS still sent back to the server by reqwest on later -// requests (only the client-side *read* is broken), so the server also +// Desktop shell exception: document.cookie can't see the CSRF cookie there +// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also // echoes the token in the login/me response BODY (sessionView's csrfToken — -// see routes/auth.ts) purely as a second channel for the desktop client to -// learn the value; desktopCsrfToken below stashes it in memory and -// setSessionUser() (called wherever a SessionUser is received) keeps it -// current. The browser path is untouched — it still reads document.cookie. +// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is +// received) stashes it via setDesktopCsrfToken(). The browser path is +// untouched — it still reads document.cookie. +import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"; import { logFailedRequest } from "./lib/logger.js"; import { apiUrl, platformFetch } from "./lib/origin.js"; import { inTauri } from "./lib/tauri-env.js"; @@ -30,16 +26,11 @@ function readCookie(name: string): string | null { return m ? decodeURIComponent(m[1]!) : null; } -/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a - * fresh app launch always logs in again, or bootstraps via /api/auth/me - * which re-supplies it). */ -let desktopCsrfToken: string | null = null; - /** Update the desktop CSRF stash. Called wherever a SessionUser is received * (login, fetchMe). No-op / cheap in the browser (the value just goes * unused there — reads still come from document.cookie). */ function setSessionUser(user: SessionUser): void { - if (user.csrfToken) desktopCsrfToken = user.csrfToken; + if (user.csrfToken) setDesktopCsrfToken(user.csrfToken); } /** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */ @@ -50,7 +41,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise headers.set("content-type", "application/json"); } if (method !== "GET" && method !== "HEAD") { - const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE); + const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE); if (csrf) headers.set(CSRF_HEADER, csrf); } const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" }); @@ -129,10 +120,19 @@ export async function login(username: string, password: string): Promise { const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); - desktopCsrfToken = null; + setDesktopCsrfToken(null); return res; } +/** Desktop only: mint a single-use, short-lived ticket that authenticates the + * live-feed WebSocket handshake in place of the session cookie — the native WS + * plugin has no cookie jar, so the cookie can never ride along (see + * routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */ +export async function fetchWsTicket(): Promise { + const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" }); + return ticket; +} + /** Persist the current user's UI language preference (restored on next login). */ export function setLanguagePref(language: Lang): Promise<{ language: Lang }> { return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) }); diff --git a/apps/web/src/lib/backend-config.ts b/apps/web/src/lib/backend-config.ts index ece5174..1ef8ab7 100644 --- a/apps/web/src/lib/backend-config.ts +++ b/apps/web/src/lib/backend-config.ts @@ -56,27 +56,28 @@ export interface BackendCheck { detail?: string; } -/** Probe a candidate origin by hitting /api/version. That route is behind - * requirePermission("site:read") (session cookie + site:read — see - * apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx; - * we're not checking "is this reachable and mine to use", only "is something - * that speaks our Fastify auth protocol listening here" — a 401 (missing/bad - * JWT) or 403 (valid session, wrong permission) from THIS specific route is - * as strong a signal of that as a 200 would be, and both are expected outcomes - * pre-login. Uses the same tauri-plugin-http path platformFetch does (raw - * fetch from the webview can't reach an arbitrary LAN host — mixed content, - * see origin.ts). */ +/** Probe a candidate origin via GET /health — the server's one unauthenticated + * route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We + * require BOTH a 2xx and that `app` value: the previous probe hit an + * auth-guarded route and accepted 401/403 as "ours", which any password- + * protected service on the LAN would also have passed. Uses the same + * tauri-plugin-http path platformFetch does (raw fetch from the webview can't + * reach an arbitrary LAN host — mixed content, see origin.ts). */ export async function testBackendUrl(url: string): Promise { const origin = url.replace(/\/$/, ""); try { const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http"); - const res = await tauriFetch(`${origin}/api/version`, { + const res = await tauriFetch(`${origin}/health`, { method: "GET", signal: AbortSignal.timeout(5000), }); - if (!res.ok && res.status !== 401 && res.status !== 403) { + if (!res.ok) { return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` }; } + const body = (await res.json().catch(() => null)) as { app?: unknown } | null; + if (body?.app !== "parking-system") { + return { ok: false, reason: "bad_response", detail: "unexpected /health body" }; + } return { ok: true }; } catch (err) { return { diff --git a/apps/web/src/lib/desktop-csrf.ts b/apps/web/src/lib/desktop-csrf.ts new file mode 100644 index 0000000..b75a31d --- /dev/null +++ b/apps/web/src/lib/desktop-csrf.ts @@ -0,0 +1,25 @@ +// Desktop-only in-memory CSRF token stash. +// +// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN +// cookie jar separate from the webview — document.cookie on tauri://localhost +// never sees the parking_csrf cookie the server sets (open upstream bug, +// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest; +// only the client-side READ is broken. So the server echoes the same value in +// the login / me response body (sessionView's csrfToken, routes/auth.ts) and +// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of +// reading document.cookie. +// +// One module, no imports, so BOTH echo sites can share it without a cycle: +// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for +// the /api/logs flush — which api.ts imports, so it can't import api.ts back). +// Never persisted: a fresh launch re-learns it via login or /api/auth/me. + +let token: string | null = null; + +export function setDesktopCsrfToken(value: string | null): void { + token = value; +} + +export function getDesktopCsrfToken(): string | null { + return token; +} diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/lib/logger.ts index b577ee1..0ee65ac 100644 --- a/apps/web/src/lib/logger.ts +++ b/apps/web/src/lib/logger.ts @@ -14,7 +14,9 @@ // high-signal sources (failed requests, uncaught errors) are always captured. import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared"; +import { getDesktopCsrfToken } from "./desktop-csrf.js"; import { apiUrl, platformFetch } from "./origin.js"; +import { inTauri } from "./tauri-env.js"; const ENDPOINT = "/api/logs"; const FLUSH_MS = 4000; @@ -75,7 +77,11 @@ async function flush(): Promise { flushing = true; try { const headers: Record = { "content-type": "application/json" }; - const csrf = readCookie(CSRF_COOKIE); + // /api/logs is behind requireAuth → assertCsrf on POST. On desktop the + // cookie is unreadable (see desktop-csrf.ts) — without this branch every + // desktop flush 403'd and was dropped here, silently, by design (found + // 2026-09-04: no desktop client log had EVER reached app_logs). + const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE); if (csrf) headers[CSRF_HEADER] = csrf; await platformFetch(apiUrl(ENDPOINT), { method: "POST", diff --git a/apps/web/src/lib/platform-ws.ts b/apps/web/src/lib/platform-ws.ts index aad8a2f..ef5de04 100644 --- a/apps/web/src/lib/platform-ws.ts +++ b/apps/web/src/lib/platform-ws.ts @@ -17,8 +17,16 @@ // Browser build: plain pass-through to the real WebSocket (this file's // createPlatformSocket is only called from inside inTauri() callers). +import { fetchWsTicket } from "../api.js"; +import { logClient } from "./logger.js"; import { inTauri } from "./tauri-env.js"; +/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s + * forever, and each attempt is a fresh adapter, so without this an outage + * would write six near-identical app_logs rows a minute. */ +const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000; +let lastConnectFailLogAt = 0; + export interface PlatformSocket { onopen: (() => void) | null; onmessage: ((ev: { data: string }) => void) | null; @@ -64,13 +72,21 @@ class TauriSocketAdapter implements PlatformSocket { try { const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket"); if (this.#closed) return; // close() called before connect resolved - // Runs on Tauri's native (Rust) side, NOT inside the webview page — there - // is no page context to auto-attach an Origin header the way a real - // browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts) - // rejects any handshake with a missing/mismatched Origin, so it must be - // set explicitly here to match what WS_ALLOWED_ORIGINS expects - // (tauri://localhost — see apps/server/.env.example). - const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } }); + // The native WS plugin is a bare tungstenite client: no page context AND + // no cookie jar. Two consequences, both handled via explicit headers: + // - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a + // browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a + // missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS. + // - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest + // jar and can't ride on this handshake, so jwtVerify() would 401 every + // connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead, + // mint a single-use ticket over normal HTTP auth and present it in the + // x-ws-ticket header (see routes/ws.ts). + const ticket = await fetchWsTicket(); + if (this.#closed) return; + const conn = await TauriWebSocket.connect(url, { + headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket }, + }); if (this.#closed) { void conn.disconnect(); return; @@ -87,7 +103,17 @@ class TauriSocketAdapter implements PlatformSocket { }); this.onopen?.(); } catch (err) { - console.error("Tauri WebSocket connect failed:", url, err); + // logClient, not console.error: console output only reaches app_logs at + // debug/trace level, which is how the ticket-less 401 stayed invisible + // for a full day. A closed-before-connect race isn't a failure. + if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) { + lastConnectFailLogAt = Date.now(); + logClient({ + level: "error", + message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`, + context: { kind: "desktop_ws_connect_failed", url }, + }); + } this.onerror?.(); this.onclose?.(); } diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index 97f0016..3cd320b 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -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`). diff --git a/wiki/log.md b/wiki/log.md index 9b3e1fe..ca6fd27 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2841,3 +2841,22 @@ value in the login/me JSON body; the desktop client stashes it in memory and ech reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified, and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]]. + +## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs + +Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix +cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly +cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and +use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second, +single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and +presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT +stays out of JS. Second finding: logger.ts read the CSRF cookie via 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, which is why "no logs whatsoever" kept happening and why yesterday's +logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts; +WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now +uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any +401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP +plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and +added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any +of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]].