Files
OwnCord/docs/plans/http-tofu-proxy.md
T
Claude dab4d73e09 feat(client): TOFU HTTP proxy for REST — close audit A-2026-07-02 (D5)
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
2026-07-19 14:26:25 +00:00

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 same cert-tofu events (first-use banner / mismatch modal); commands start_http_proxy / stop_http_proxy; mismatch returns a clean 502 to the loopback fetch. Registered in lib.rs.
  • src/lib/httpProxy.tsensureHttpProxy(host) (per-host cache + concurrent-start dedup) / stopHttpProxy(host).
  • api.ts, profiles.ts (health), attachments.ts (image + download) now resolve server URLs to http://127.0.0.1:{port}; all acceptInvalidCerts usage and the allowSelfSigned config field are removed, and the dangerous-settings feature is dropped from Cargo.toml.
  • capabilities/default.json gains http://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.rs already contains the two building blocks to extract into a shared module (tls_tunnel.rs): the loopback TcpListener accept loop and the PinnedCertVerifier (SHA-256 colon-hex fingerprint check, livekit_proxy.rs ~line 79).
  • HTTP/1.1 keep-alive works transparently over a byte tunnel. The Host header sent by the webview must be rewritten? No — configure the API client to send the real host in Host (tauri-plugin-http keeps the URL's host; since the URL is http://127.0.0.1:{port}, inject a Host: {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

  1. Pure byte tunnel (smallest): identical to livekit_proxy. Requires the TS client to set Host explicitly per request (tauri-plugin-http allows custom headers; verify it doesn't override Host — if it does, fall back to variant 2).
  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.json via commands.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, emit cert-tofu event (banner); known host + mismatch → refuse the connection and emit the mismatch event (CertMismatchModal flow, reusing accept_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: createApiClient gains a baseUrl of http://127.0.0.1:{port} resolved via the proxy; delete the allowSelfSigned flag and the danger: fetch options entirely. The dangerous-settings feature flag on tauri-plugin-http can then be dropped from src-tauri/Cargo.toml — build fails if any dangling acceptInvalidCerts remains, 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.