The REST path previously used tauri-plugin-http with danger.acceptInvalidCerts, so it accepted ANY certificate while the WS and LiveKit paths were TOFU-pinned in Rust — and the bearer token rides every REST request. This routes REST through a new Rust loopback TCP->TLS proxy that pins the server certificate to the same trust-on-first-use fingerprint as the WS proxy. Rust (src-tauri): - New http_proxy.rs: per-host loopback tunnels (HttpProxyState map); per-connection TOFU via CaptureVerifier + tofu_check, sharing ws_proxy's cert store (cert_store_key) and emitting the same cert-tofu events (first-use banner / mismatch modal). First request's Host is rewritten and Connection: close injected so one request rides each connection. Mismatch returns a clean 502 to the loopback fetch. - Register HttpProxyState + start_http_proxy/stop_http_proxy in lib.rs. - Drop the dangerous-settings feature from tauri-plugin-http. TypeScript (src): - New lib/httpProxy.ts: ensureHttpProxy(host) (per-host cache + concurrent-start dedup) / stopHttpProxy(host). - api.ts, profiles.ts (health), attachments.ts (image + download) resolve server URLs to http://127.0.0.1:{port}; remove the allowSelfSigned config field and every acceptInvalidCerts block. External hosts (CDNs, OG previews, YouTube) keep normal TLS validation. - main.ts constructs the API client without allowSelfSigned. - capabilities/default.json: allow http://127.0.0.1:* fetch scope. Tests: - New tests/unit/http-proxy.test.ts (cache, dedup, stop/restart). - api.test.ts and attachments-render.test.ts: mock httpProxy, replace the acceptInvalidCerts assertions with proxy-origin assertions. Verified: tsc --noEmit clean; new + affected vitest suites green (176 tests); the http_proxy pure logic (host validation, header rewrite) passes as standalone Rust unit tests; oxlint/eslint counts unchanged from HEAD; prettier clean. The full Tauri build (cargo) requires GUI system libs not present in this environment and runs on CI/real runners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
5.9 KiB
Client HTTP TOFU Proxy (D5) — Design
Status: implemented 2026-07-19 Decision: D5 in audit-2026-07-19-decisions.md — "next security work" Closes: audit finding A-2026-07-02 (client HTTP path accepts any TLS certificate)
Implementation summary (what shipped)
Chose variant 1 (byte tunnel) with a targeted header rewrite: the first
request's Host is rewritten to the real host and Connection: close is
injected so exactly one request rides each tunnel connection (no keep-alive
reuse that would bypass the rewrite).
src-tauri/src/http_proxy.rs— per-host loopback TCP→TLS tunnels (HttpProxyState=HashMap<host, ProxyEntry>); per-connection TOFU (CaptureVerifier+tofu_check) sharing ws_proxy's cert store (cert_store_key) and emitting the samecert-tofuevents (first-use banner / mismatch modal); commandsstart_http_proxy/stop_http_proxy; mismatch returns a clean502to the loopback fetch. Registered inlib.rs.src/lib/httpProxy.ts—ensureHttpProxy(host)(per-host cache + concurrent-start dedup) /stopHttpProxy(host).api.ts,profiles.ts(health),attachments.ts(image + download) now resolve server URLs tohttp://127.0.0.1:{port}; allacceptInvalidCertsusage and theallowSelfSignedconfig field are removed, and thedangerous-settingsfeature is dropped fromCargo.toml.capabilities/default.jsongainshttp://127.0.0.1:*fetch scope; CSP already allowed loopback.
External hosts (image CDNs, OG previews, YouTube) keep normal TLS validation.
Problem
Every REST call from the client uses tauri-plugin-http with
danger: { acceptInvalidCerts: true } (allowSelfSigned hardcoded in
src/main.ts), and the bearer token rides on every request. The WS path
(ws_proxy.rs) and the LiveKit path (livekit_proxy.rs) pin a
trust-on-first-use SHA-256 certificate fingerprint per host; the HTTP path is
the only unpinned transport. An active MITM can capture session tokens without
ever triggering the cert-mismatch UI.
Approach — loopback TCP→TLS tunnel (reuse the LiveKit proxy pattern)
Add src-tauri/src/http_proxy.rs, structurally a sibling of
livekit_proxy.rs: a plain TCP listener on 127.0.0.1:{ephemeral} that
byte-shovels to https://{host}:{port} over rustls with a pinned-fingerprint
verifier. The webview then talks plain HTTP to loopback, and all TLS trust
decisions live in Rust:
livekit_proxy.rsalready contains the two building blocks to extract into a shared module (tls_tunnel.rs): the loopbackTcpListeneraccept loop and thePinnedCertVerifier(SHA-256 colon-hex fingerprint check,livekit_proxy.rs~line 79).- HTTP/1.1 keep-alive works transparently over a byte tunnel. The
Hostheader sent by the webview must be rewritten? No — configure the API client to send the real host inHost(tauri-plugin-http keeps the URL's host; since the URL ishttp://127.0.0.1:{port}, inject aHost: {real}header explicitly, or terminate HTTP in the proxy — see "Two variants"). TLS SNI is handled by the tunnel (it dials by hostname).
Two variants, pick at implementation time
- Pure byte tunnel (smallest): identical to livekit_proxy. Requires the
TS client to set
Hostexplicitly per request (tauri-plugin-http allows custom headers; verify it doesn't overrideHost— if it does, fall back to variant 2). - Minimal HTTP-aware proxy: parse only the request line + headers,
rewrite
Host, then stream bodies both ways. More code, but removes the header caveat and allows per-request logging. Still no TLS termination in the webview.
TOFU semantics (must match ws_proxy)
- Pin store: the same per-host fingerprint store used by
ws_proxy.rs(certs.jsonviacommands.rs); one fingerprint per host covers all three transports. - First contact: unlike today, the first TLS contact with a server is
the login HTTP request, not the WS connect. The HTTP proxy must therefore
implement the same first-trust flow as
ws_proxy.rs: unknown host → accept, store fingerprint, emitcert-tofuevent (banner); known host + mismatch → refuse the connection and emit the mismatch event (CertMismatchModalflow, reusingaccept_cert_fingerprint,ws_proxy.rs~line 419). - Rotation: accepting a new fingerprint in the modal must apply to all three transports at once (single store already guarantees this).
Lifecycle & wiring
- Commands:
http_proxy_start(host, port) -> u16(idempotent per host, returns loopback port),http_proxy_stop(host). One tunnel per host — the Connect page's multi-profile health polling (15s) starts tunnels on demand for each profile it polls; quick-switch stops the old host's tunnel. - TS changes:
createApiClientgains abaseUrlofhttp://127.0.0.1:{port}resolved via the proxy; delete theallowSelfSignedflag and thedanger:fetch options entirely. Thedangerous-settingsfeature flag on tauri-plugin-http can then be dropped fromsrc-tauri/Cargo.toml— build fails if any danglingacceptInvalidCertsremains, which is the desired ratchet. - The self-hosted updater (
update_commands.rs) already pins TLS itself — unchanged. - CSP already allows localhost connections (
tauri.conf.json).
Testing
- Rust: unit tests for the verifier (match/mismatch/unknown-host TOFU), and
an integration test dialing a local TLS listener with a self-signed cert
(mirror
ws_proxy.rs's existing test style). - TS: api tests swap to the loopback base URL; add a regression test that no
code path passes
acceptInvalidCerts. - Manual: first connect (banner), cert rotation (modal), multi-profile health polling, large upload/download streaming through the tunnel.
Non-goals
- No system-proxy support changes, no HTTP/2 (server is HTTP/1.1 via chi), no change to the WS or LiveKit proxies.