From dab4d73e09c536f180027aa1703eecf9199b5d52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:26:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(client):=20TOFU=20HTTP=20proxy=20for=20RES?= =?UTF-8?q?T=20=E2=80=94=20close=20audit=20A-2026-07-02=20(D5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA --- Client/tauri-client/src-tauri/Cargo.toml | 12 +- .../src-tauri/capabilities/default.json | 9 + .../tauri-client/src-tauri/src/http_proxy.rs | 552 ++++++++++++++++++ Client/tauri-client/src-tauri/src/lib.rs | 4 + .../components/message-list/attachments.ts | 38 +- Client/tauri-client/src/lib/api.ts | 54 +- Client/tauri-client/src/lib/httpProxy.ts | 59 ++ Client/tauri-client/src/lib/profiles.ts | 13 +- Client/tauri-client/src/main.ts | 11 +- Client/tauri-client/tests/unit/api.test.ts | 59 +- .../tests/unit/attachments-render.test.ts | 23 +- .../tests/unit/http-proxy.test.ts | 64 ++ docs/audit-2026-07-19.md | 4 +- docs/plans/audit-2026-07-19-decisions.md | 2 +- docs/plans/http-tofu-proxy.md | 27 +- 15 files changed, 819 insertions(+), 112 deletions(-) create mode 100644 Client/tauri-client/src-tauri/src/http_proxy.rs create mode 100644 Client/tauri-client/src/lib/httpProxy.ts create mode 100644 Client/tauri-client/tests/unit/http-proxy.test.ts diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 3e87ee0d..819370fd 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -23,12 +23,12 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -# NOTE: dangerous-settings is required for self-signed certificate support. -# The app is designed for self-hosted servers which commonly use self-signed certs. -# The Rust TOFU WS proxy handles WebSocket certs, but HTTP API calls (health, -# login, upload) and attachment downloads need this feature to accept self-signed -# certs via the `danger` fetch option. Scoped to server URLs only in attachments.ts. -tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-settings"] } +# Self-signed server certificates are handled by the Rust TOFU proxies +# (ws_proxy, livekit_proxy, http_proxy), NOT by the plugin's dangerous-settings +# feature — REST traffic is tunneled through http_proxy which pins the cert to +# the trust-on-first-use fingerprint. The plugin therefore does default TLS +# validation (used only for external hosts: image CDNs, OG previews, YouTube). +tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index d51d6c58..734a567e 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -38,6 +38,9 @@ }, { "url": "https://*" + }, + { + "url": "http://127.0.0.1:*" } ] }, @@ -49,6 +52,9 @@ }, { "url": "https://*" + }, + { + "url": "http://127.0.0.1:*" } ] }, @@ -60,6 +66,9 @@ }, { "url": "https://*" + }, + { + "url": "http://127.0.0.1:*" } ] }, diff --git a/Client/tauri-client/src-tauri/src/http_proxy.rs b/Client/tauri-client/src-tauri/src/http_proxy.rs new file mode 100644 index 00000000..4f297b79 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/http_proxy.rs @@ -0,0 +1,552 @@ +// Local TCP-to-TLS proxy for REST/HTTP requests — closes audit A-2026-07-02. +// +// Problem: HTTP API calls previously used tauri-plugin-http with +// `danger.acceptInvalidCerts`, so the REST path (which carries the bearer +// token on every request) accepted ANY certificate while the WebSocket and +// LiveKit paths were TOFU-pinned in Rust. +// +// Solution: This module starts one plain TCP listener on localhost per remote +// host. The webview fetches http://127.0.0.1:{port}/api/v1/... and the proxy +// opens a TLS connection to the real server, enforcing the same TOFU +// (Trust On First Use) fingerprint pinning as ws_proxy: +// - Unknown host → accept, persist the fingerprint, emit `cert-tofu` +// (status "trusted_first_use") so the UI can show the banner. HTTP is the +// FIRST TLS contact with a server (login precedes the WS connect), so this +// proxy — not ws_proxy — usually establishes the pin. +// - Pinned host → fingerprint must match or the connection is refused and a +// `cert-tofu` mismatch event fires (CertMismatchModal flow). +// +// Design notes (docs/plans/http-tofu-proxy.md): +// - One request per tunnel connection: the proxy rewrites the first request's +// Host header to the real host and injects `Connection: close`, so +// keep-alive reuse (whose later requests would bypass the rewrite) never +// happens. Per-request TLS overhead is acceptable for this app's REST +// traffic; the hot path is the WebSocket. +// - Per-host tunnels: the Connect page polls health for every profile, so +// multiple proxies can run concurrently (bounded by profile count). +// - The accept loop exits after 5 consecutive errors to prevent CPU spin. + +use log::{debug, error, info, warn}; +use ring::digest::{digest, SHA256}; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; +use rustls::pki_types::ServerName; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Runtime}; +use tauri_plugin_store::StoreExt; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; +use tokio::time::{timeout, Duration}; + +use crate::constants::CERTS_STORE; +use crate::livekit_proxy::cert_store_key; + +/// Tauri-managed state: one running tunnel per remote host. +pub struct HttpProxyState { + inner: Mutex>, +} + +struct ProxyEntry { + port: u16, + shutdown_tx: tokio::sync::oneshot::Sender<()>, +} + +impl HttpProxyState { + pub fn new() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + } + } +} + +/// Validate a remote host string before it is used in header rewriting and +/// dialing. Mirrors start_livekit_proxy's checks. +fn validate_remote_host(remote_host: &str) -> Result<(), String> { + if remote_host.is_empty() || remote_host.len() > 260 { + return Err("remote_host is empty or too long".into()); + } + if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') { + return Err("remote_host contains invalid characters".into()); + } + if !remote_host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) + { + return Err("remote_host contains unexpected characters".into()); + } + Ok(()) +} + +/// Start (or reuse) a local HTTP→TLS tunnel for `remote_host` and return the +/// loopback port. The webview should send its REST traffic to +/// `http://127.0.0.1:{port}`. +#[tauri::command] +pub async fn start_http_proxy( + app: AppHandle, + state: tauri::State<'_, HttpProxyState>, + remote_host: String, +) -> Result { + validate_remote_host(&remote_host)?; + + let mut inner = state.inner.lock().await; + + if let Some(entry) = inner.get(&remote_host) { + debug!( + "[http_proxy] reusing tunnel on port {} for {}", + entry.port, remote_host + ); + return Ok(entry.port); + } + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("http proxy bind failed: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("http proxy local_addr: {e}"))? + .port(); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + tokio::spawn(run_proxy_loop( + app.clone(), + listener, + remote_host.clone(), + shutdown_rx, + )); + + info!( + "[http_proxy] tunnel started on 127.0.0.1:{} → {}", + port, remote_host + ); + inner.insert(remote_host, ProxyEntry { port, shutdown_tx }); + Ok(port) +} + +/// Stop the tunnel for `remote_host` (no-op if none is running). +#[tauri::command] +pub async fn stop_http_proxy( + state: tauri::State<'_, HttpProxyState>, + remote_host: String, +) -> Result<(), String> { + let mut inner = state.inner.lock().await; + if let Some(entry) = inner.remove(&remote_host) { + let _ = entry.shutdown_tx.send(()); + info!("[http_proxy] tunnel stopped for {}", remote_host); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// TOFU verification (mirrors ws_proxy semantics; shared cert store) +// --------------------------------------------------------------------------- + +/// Fingerprint captured during the TLS handshake. +type CapturedFingerprint = Arc>>; + +/// Accepts the handshake while recording the leaf certificate's SHA-256 +/// fingerprint; the TOFU decision happens immediately after the handshake, +/// before any request bytes are forwarded. +#[derive(Debug)] +struct CaptureVerifier { + captured: CapturedFingerprint, +} + +impl CaptureVerifier { + fn new() -> (Self, CapturedFingerprint) { + let fp = Arc::new(std::sync::Mutex::new(None)); + (Self { captured: fp.clone() }, fp) + } +} + +impl rustls::client::danger::ServerCertVerifier for CaptureVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + let hash = digest(&SHA256, end_entity.as_ref()); + let hex = hash + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":"); + if let Ok(mut guard) = self.captured.lock() { + *guard = Some(hex); + } + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// TOFU decision for `host` (cert-store key, i.e. without a default :443): +/// first use stores the pin, match passes, mismatch fails. Same store, same +/// save-rollback behavior, and same event payloads as ws_proxy::tofu_check. +fn tofu_check( + app: &AppHandle, + host: &str, + fingerprint: &str, +) -> Result { + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + + let stored = store.get(host).and_then(|v| { + if let Value::String(s) = v { + Some(s) + } else { + None + } + }); + + match stored { + None => { + let old_value = store.get(host); + store.set(host, Value::String(fingerprint.to_string())); + if let Err(e) = store.save() { + match old_value { + Some(v) => { + store.set(host, v); + } + None => { + let _ = store.delete(host); + } + } + return Err(format!("failed to persist cert fingerprint: {e}")); + } + Ok("trusted_first_use".to_string()) + } + Some(ref stored_fp) if stored_fp == fingerprint => Ok("trusted".to_string()), + Some(stored_fp) => Err(format!( + "Certificate fingerprint changed for {host}.\n\ + Stored: {stored_fp}\n\ + Current: {fingerprint}\n\ + This may indicate a man-in-the-middle attack or a server certificate rotation.\n\ + Use accept_cert_fingerprint to trust the new certificate." + )), + } +} + +// --------------------------------------------------------------------------- +// Proxy internals +// --------------------------------------------------------------------------- + +/// Maximum consecutive accept errors before the proxy loop exits. +const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5; + +async fn run_proxy_loop( + app: AppHandle, + listener: TcpListener, + remote_host: String, + mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, +) { + let mut consecutive_errors: u32 = 0; + + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + consecutive_errors = 0; + let host = remote_host.clone(); + let app = app.clone(); + debug!("[http_proxy] accepted connection from {}", addr); + tokio::spawn(async move { + if let Err(e) = handle_connection(app, stream, &host).await { + warn!("[http_proxy] connection to {} failed: {}", host, e); + } + }); + } + Err(e) => { + consecutive_errors += 1; + error!( + "[http_proxy] accept error ({}/{}): {}", + consecutive_errors, MAX_CONSECUTIVE_ACCEPT_ERRORS, e + ); + if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS { + error!( + "[http_proxy] {} consecutive accept errors, stopping proxy loop", + MAX_CONSECUTIVE_ACCEPT_ERRORS + ); + break; + } + } + } + } + _ = &mut shutdown_rx => break, + } + } +} + +/// Rewrite the first request's headers: replace Host with the real remote +/// host and force `Connection: close` so exactly one request rides each +/// tunnel connection (later keep-alive requests would bypass this rewrite). +/// `raw` must end with the "\r\n\r\n" header terminator. +fn rewrite_request_headers(raw: &[u8], remote_host: &str) -> String { + let request = String::from_utf8_lossy(raw); + let mut modified = String::with_capacity(raw.len() + 128); + let mut lines = request.split("\r\n").peekable(); + let mut first = true; + while let Some(line) = lines.next() { + if !first { + modified.push_str("\r\n"); + } + first = false; + // The terminator produces two trailing empty strings; emit them as-is. + if line.is_empty() && lines.peek().is_none() { + break; + } + let lower = line.to_ascii_lowercase(); + if lower.starts_with("host:") { + modified.push_str("Host: "); + modified.push_str(remote_host); + } else if lower.starts_with("connection:") { + modified.push_str("Connection: close"); + } else { + modified.push_str(line); + } + } + // `break` above consumed one empty segment; restore the full terminator. + if !modified.ends_with("\r\n\r\n") { + while !modified.ends_with("\r\n\r\n") { + modified.push_str("\r\n"); + } + } + // If the client never sent a Connection header, inject one. + if !modified.to_ascii_lowercase().contains("\r\nconnection:") { + let insert_at = modified.len() - 2; // before final CRLF + modified.insert_str(insert_at, "Connection: close\r\n"); + } + modified +} + +/// Handle one proxied connection: +/// 1. Read the request headers from the loopback side +/// 2. TLS-connect to the remote and run the TOFU check (store/emit/reject) +/// 3. Forward the rewritten request, then shovel bytes bidirectionally +async fn handle_connection( + app: AppHandle, + mut local: TcpStream, + remote_host: &str, +) -> Result<(), Box> { + // ── 1. Read HTTP request headers (up to \r\n\r\n), 10s guard ───────── + let mut buf = Vec::with_capacity(4096); + timeout(Duration::from_secs(10), async { + let mut trailer = [0u8; 4]; + loop { + let mut byte = [0u8; 1]; + local.read_exact(&mut byte).await?; + buf.push(byte[0]); + trailer[0] = trailer[1]; + trailer[1] = trailer[2]; + trailer[2] = trailer[3]; + trailer[3] = byte[0]; + if trailer == *b"\r\n\r\n" { + break; + } + if buf.len() > 16_384 { + return Err(Box::::from( + "HTTP request headers too large", + )); + } + } + Ok::<(), Box>(()) + }) + .await + .map_err(|_| { + Box::::from("request header read timed out") + })??; + + // Defense-in-depth (primary validation is in start_http_proxy). + validate_remote_host(remote_host)?; + let modified = rewrite_request_headers(&buf, remote_host); + + // ── 2. TLS connect + TOFU check ────────────────────────────────────── + let (verifier, captured_fp) = CaptureVerifier::new(); + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + + let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443")); + let hostname = raw_hostname.trim_start_matches('[').trim_end_matches(']'); + let server_name = if let Ok(ip) = hostname.parse::() { + ServerName::IpAddress(ip.into()) + } else { + ServerName::try_from(hostname.to_string()) + .map_err(|e| format!("invalid server name '{hostname}': {e}"))? + }; + + let dial_target = if remote_host.contains(':') { + remote_host.to_string() + } else { + format!("{remote_host}:443") + }; + let tcp = timeout(Duration::from_secs(10), TcpStream::connect(&dial_target)) + .await + .map_err(|_| Box::::from("TCP connect timed out"))??; + let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp)) + .await + .map_err(|_| Box::::from("TLS handshake timed out"))??; + + let fingerprint = captured_fp + .lock() + .map_err(|e| format!("failed to read captured fingerprint: {e}"))? + .clone() + .unwrap_or_default(); + if fingerprint.is_empty() { + return Err("TLS handshake completed but no certificate fingerprint was captured".into()); + } + + let store_key = cert_store_key(remote_host); + match tofu_check(&app, &store_key, &fingerprint) { + Ok(status) => { + if status == "trusted_first_use" { + info!("[http_proxy] TOFU first-use pin for {}", store_key); + } + let _ = app.emit( + "cert-tofu", + serde_json::json!({ + "host": store_key, + "fingerprint": fingerprint, + "status": status, + }), + ); + } + Err(mismatch_msg) => { + warn!( + "[http_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", + store_key + ); + let _ = app.emit( + "cert-tofu", + serde_json::json!({ + "host": store_key, + "fingerprint": fingerprint, + "status": "mismatch", + "message": mismatch_msg, + }), + ); + // Give the local fetch a clean HTTP failure instead of a reset. + let _ = local + .write_all( + b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ) + .await; + return Err(mismatch_msg.into()); + } + } + + // ── 3. Forward request + bidirectional copy ────────────────────────── + tls.write_all(modified.as_bytes()).await?; + match io::copy_bidirectional(&mut local, &mut tls).await { + Ok((to_remote, from_remote)) => { + debug!( + "[http_proxy] connection closed: {}B sent, {}B received", + to_remote, from_remote + ); + } + Err(e) => { + debug!("[http_proxy] bidirectional copy ended: {}", e); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_crlf_and_null() { + assert!(validate_remote_host("evil\r\nhost").is_err()); + assert!(validate_remote_host("evil\0host").is_err()); + } + + #[test] + fn validate_rejects_empty_and_odd_chars() { + assert!(validate_remote_host("").is_err()); + assert!(validate_remote_host("host name").is_err()); + assert!(validate_remote_host("host/path").is_err()); + } + + #[test] + fn validate_accepts_typical_hosts() { + assert!(validate_remote_host("example.com:8443").is_ok()); + assert!(validate_remote_host("192.168.1.10:8443").is_ok()); + assert!(validate_remote_host("[::1]:8443").is_ok()); + } + + #[test] + fn rewrite_replaces_host_and_forces_close() { + let raw = b"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nAccept: */*\r\n\r\n"; + let out = rewrite_request_headers(raw, "example.com:8443"); + assert!(out.contains("Host: example.com:8443\r\n")); + assert!(!out.contains("127.0.0.1")); + assert!(out.to_ascii_lowercase().contains("connection: close")); + assert!(out.ends_with("\r\n\r\n")); + } + + #[test] + fn rewrite_overrides_existing_keepalive() { + let raw = + b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n"; + let out = rewrite_request_headers(raw, "example.com:8443"); + assert!(out.contains("Connection: close\r\n")); + assert!(!out.to_ascii_lowercase().contains("keep-alive")); + // Exactly one Connection header. + assert_eq!(out.to_ascii_lowercase().matches("\r\nconnection:").count(), 1); + } + + #[test] + fn rewrite_preserves_other_headers_and_body_boundary() { + let raw = b"POST /api/v1/auth/login HTTP/1.1\r\nHost: 127.0.0.1:9\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n"; + let out = rewrite_request_headers(raw, "myserver.lan:8443"); + assert!(out.contains("Content-Type: application/json\r\n")); + assert!(out.contains("Content-Length: 2\r\n")); + assert!(out.ends_with("\r\n\r\n")); + } +} diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 90e2cb91..2b1230f4 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod constants; mod credentials; +mod http_proxy; mod livekit_proxy; mod ptt; mod tray; @@ -21,6 +22,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .manage(ws_proxy::WsState::new()) .manage(livekit_proxy::LiveKitProxyState::new()) + .manage(http_proxy::HttpProxyState::new()) .invoke_handler(tauri::generate_handler![ commands::get_settings, commands::save_settings, @@ -42,6 +44,8 @@ pub fn run() { ptt::ptt_listen_for_key, livekit_proxy::start_livekit_proxy, livekit_proxy::stop_livekit_proxy, + http_proxy::start_http_proxy, + http_proxy::stop_http_proxy, #[cfg(feature = "devtools")] commands::open_devtools, ]) diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts index 16ebb24f..d7569de1 100644 --- a/Client/tauri-client/src/components/message-list/attachments.ts +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -9,6 +9,7 @@ import { observeMedia } from "@lib/media-visibility"; import { loadPref } from "@components/settings/helpers"; import { createLogger } from "@lib/logger"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { ensureHttpProxy } from "@lib/httpProxy"; import { save } from "@tauri-apps/plugin-dialog"; const log = createLogger("attachments"); @@ -114,6 +115,19 @@ export function isTrustedServerUrl(url: string): boolean { return isServerUrl(url); } +/** + * If `url` targets the OwnCord server, return an equivalent URL pointing at the + * Rust HTTP TOFU proxy's loopback origin (cert-pinned) with the same path and + * query. Non-server URLs (external images) are returned unchanged so they use a + * normal validated HTTPS fetch. + */ +async function toFetchUrl(url: string): Promise { + if (!isServerUrl(url)) return url; + const parsed = new URL(url); + const origin = await ensureHttpProxy(parsed.host); + return `${origin}${parsed.pathname}${parsed.search}`; +} + /** In-flight fetch promises to prevent duplicate concurrent requests. */ const inFlight = new Map>(); @@ -225,18 +239,12 @@ export function fetchImageAsDataUrl(url: string): Promise { return idbCached; } - // 4. Network fetch via Tauri HTTP plugin - // acceptInvalidCerts is required for self-hosted OwnCord servers with self-signed - // TLS certificates. This means the client will accept any certificate from any server - // for image fetching, which could enable SSRF to internal endpoints via malicious - // chat messages containing internal URLs. Mitigated by: (1) isSafeUrl only allows - // http/https, (2) responses are only used as image data, not executed. + // 4. Network fetch. Server-hosted images go through the Rust HTTP TOFU + // proxy (cert-pinned, same trust store as the WS proxy); external images + // use a normal validated HTTPS fetch. isSafeUrl restricts to http/https and + // responses are only used as image data, never executed. try { - const useInsecure = isServerUrl(url); - const fetchOpts: RequestInit = useInsecure - ? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit) - : {}; - const res = await tauriFetch(url, fetchOpts); + const res = await tauriFetch(await toFetchUrl(url)); if (!res.ok) return null; const rawCt = res.headers.get("content-type") ?? ""; @@ -396,12 +404,8 @@ async function downloadFile(url: string, filename: string): Promise { const filePath = await save({ defaultPath: filename }); if (filePath === null) return; // User cancelled - // Fetch file data — only accept invalid certs for the OwnCord server - const useInsecure = isServerUrl(url); - const fetchOpts: RequestInit = useInsecure - ? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit) - : {}; - const res = await tauriFetch(url, fetchOpts); + // Fetch file data — server downloads go through the cert-pinned HTTP proxy. + const res = await tauriFetch(await toFetchUrl(url)); if (!res.ok) { log.error("Download failed", { filename, status: res.status }); alert(`Download failed: server returned ${res.status}`); diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 81f1e667..46c364c4 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -3,6 +3,7 @@ import { fetch } from "@tauri-apps/plugin-http"; import { createLogger } from "./logger"; +import { ensureHttpProxy } from "./httpProxy"; import type { AuthResponse, RegisterResponse, @@ -27,8 +28,6 @@ import type { export interface ApiClientConfig { readonly host: string; readonly token?: string; - /** Accept self-signed TLS certificates (for local/dev OwnCord servers). */ - readonly allowSelfSigned?: boolean; } /** API client error with parsed error body. */ @@ -57,12 +56,16 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: let config = { ...initialConfig }; - function baseUrl(): string { - return `https://${config.host}/api/v1`; + // REST traffic is tunneled through the Rust HTTP TOFU proxy: instead of + // hitting https://{host} directly (which used to require accepting invalid + // certs), we hit http://127.0.0.1:{port} where the proxy pins the server + // certificate to the same trust-on-first-use fingerprint as the WS proxy. + async function baseUrl(): Promise { + return `${await ensureHttpProxy(config.host)}/api/v1`; } - function adminBaseUrl(): string { - return `https://${config.host}/admin/api`; + async function adminBaseUrl(): Promise { + return `${await ensureHttpProxy(config.host)}/admin/api`; } function headers(): Record { @@ -84,15 +87,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: signal?: AbortSignal, ): Promise { const url = `${urlBase}${path}`; - const init: RequestInit & { - danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean }; - } = { + const init: RequestInit = { method, headers: headers(), signal, - ...(config.allowSelfSigned === true - ? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } - : {}), }; if (body !== undefined) { init.body = JSON.stringify(body); @@ -141,22 +139,22 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: return res.json() as Promise; } - function request( + async function request( method: string, path: string, body?: unknown, signal?: AbortSignal, ): Promise { - return doFetch("API", baseUrl(), method, path, body, signal); + return doFetch("API", await baseUrl(), method, path, body, signal); } - function adminRequest( + async function adminRequest( method: string, path: string, body?: unknown, signal?: AbortSignal, ): Promise { - return doFetch("Admin API", adminBaseUrl(), method, path, body, signal); + return doFetch("Admin API", await adminBaseUrl(), method, path, body, signal); } // oxlint-disable-next-line consistent-function-scoping -- co-located with doFetch for encapsulation @@ -220,10 +218,8 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: signal?: AbortSignal, ): Promise { // Don't mutate shared config — make direct fetch with the partial token - const url = `${baseUrl()}/auth/verify-totp`; - const init: RequestInit & { - danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean }; - } = { + const url = `${await baseUrl()}/auth/verify-totp`; + const init: RequestInit = { method: "POST", headers: { "Content-Type": "application/json", @@ -231,9 +227,6 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: }, body: JSON.stringify({ code }), signal, - ...(config.allowSelfSigned === true - ? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } - : {}), }; let res: Response; @@ -370,7 +363,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: const formData = new FormData(); formData.append("file", file); - const url = `${baseUrl()}/uploads`; + const url = `${await baseUrl()}/uploads`; const h: Record = {}; if (config.token) { h["Authorization"] = `Bearer ${config.token}`; @@ -382,10 +375,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: headers: h, body: formData, signal, - ...(config.allowSelfSigned === true - ? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } - : {}), - } as RequestInit); + }); if (!res.ok) { const err = await parseError(res); @@ -462,12 +452,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(`https://${targetHost}/api/v1/health`, { + const origin = await ensureHttpProxy(targetHost); + const res = await fetch(`${origin}/api/v1/health`, { signal: controller.signal, - ...(config.allowSelfSigned === true - ? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } - : {}), - } as RequestInit); + }); if (!res.ok) { throw new ApiClientError(res.status, "HEALTH_CHECK_FAILED", "Health check failed"); } diff --git a/Client/tauri-client/src/lib/httpProxy.ts b/Client/tauri-client/src/lib/httpProxy.ts new file mode 100644 index 00000000..701b6e7d --- /dev/null +++ b/Client/tauri-client/src/lib/httpProxy.ts @@ -0,0 +1,59 @@ +// Client-side helper for the Rust HTTP TOFU proxy (closes audit A-2026-07-02). +// +// The Rust `http_proxy` module runs one loopback TCP→TLS tunnel per remote +// host and pins the server certificate with the same trust-on-first-use store +// as the WebSocket proxy. REST calls go to http://127.0.0.1:{port} instead of +// https://{host} directly, so the webview never has to accept an invalid +// certificate and the bearer token never rides an unpinned TLS connection. +// +// This module maps a remote host ("host" or "host:port") to its loopback +// origin ("http://127.0.0.1:{port}"), caching per host and de-duplicating +// concurrent starts so parallel requests share one tunnel. + +import { invoke } from "@tauri-apps/api/core"; +import { createLogger } from "./logger"; + +const log = createLogger("http-proxy"); + +/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */ +const origins = new Map(); +/** host → in-flight start so concurrent callers don't race the tunnel. */ +const pending = new Map>(); + +/** + * Ensure a tunnel exists for `host` and return its loopback origin + * (no trailing slash). Idempotent and concurrency-safe per host. + */ +export async function ensureHttpProxy(host: string): Promise { + const cached = origins.get(host); + if (cached) return cached; + + const inFlight = pending.get(host); + if (inFlight) return inFlight; + + const start = (async () => { + const port = await invoke("start_http_proxy", { remoteHost: host }); + const origin = `http://127.0.0.1:${port}`; + origins.set(host, origin); + log.debug("tunnel ready", { host, origin }); + return origin; + })(); + + pending.set(host, start); + try { + return await start; + } finally { + pending.delete(host); + } +} + +/** Stop the tunnel for `host` and drop its cached origin (best-effort). */ +export async function stopHttpProxy(host: string): Promise { + origins.delete(host); + pending.delete(host); + try { + await invoke("stop_http_proxy", { remoteHost: host }); + } catch (err) { + log.debug("stop_http_proxy failed (ignored)", { host, error: String(err) }); + } +} diff --git a/Client/tauri-client/src/lib/profiles.ts b/Client/tauri-client/src/lib/profiles.ts index 0d3856c0..f7678feb 100644 --- a/Client/tauri-client/src/lib/profiles.ts +++ b/Client/tauri-client/src/lib/profiles.ts @@ -8,6 +8,7 @@ import { createStore, type Store } from "./store"; import { fetch } from "@tauri-apps/plugin-http"; +import { ensureHttpProxy } from "./httpProxy"; // --------------------------------------------------------------------------- // Constants @@ -192,6 +193,15 @@ export function createProfileManager( // Resolve which fetch to use: injected mock, Tauri plugin, or global const doFetch: FetchFn = fetchFn ?? fetch; + // Resolve the origin for a health check. With an injected fetch (tests) we + // keep the direct https URL the mock expects; otherwise we route through the + // Rust HTTP TOFU proxy so profile health checks are cert-pinned like the + // rest of the REST surface. + async function resolveHealthOrigin(host: string): Promise { + if (fetchFn) return `https://${host}`; + return ensureHttpProxy(host); + } + // ── Helpers ──────────────────────────────────────────────── function currentProfiles(): readonly ServerProfile[] { @@ -228,7 +238,8 @@ export function createProfileManager( const start = performance.now(); try { - const res = await doFetch(`https://${host}/api/v1/health`, { + const origin = await resolveHealthOrigin(host); + const res = await doFetch(`${origin}/api/v1/health`, { signal: controller.signal, }); const elapsed = Math.round(performance.now() - start); diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index b77c5e0c..850a034f 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -85,12 +85,11 @@ if (!appEl) { // Create core services const router = createRouter("connect"); -// NOTE: allowSelfSigned must be true because the app targets self-hosted servers -// that commonly use self-signed certificates. The Rust TOFU WS proxy handles -// WebSocket certs, but HTTP API calls (health, login, register, upload) have no -// equivalent proxy and need this flag to function. The ideal future fix is adding -// a TOFU HTTP proxy in Rust alongside the existing WS proxy. -const api = createApiClient({ host: "", allowSelfSigned: true }, () => { +// REST traffic is tunneled through the Rust HTTP TOFU proxy (src/lib/httpProxy.ts +// → src-tauri/src/http_proxy.rs), which pins the server certificate to the same +// trust-on-first-use fingerprint as the WS proxy. No cert is ever blindly +// accepted; the bearer token never rides an unpinned TLS connection. +const api = createApiClient({ host: "" }, () => { log.warn("Session expired (401), clearing auth"); clearAuth(); }); diff --git a/Client/tauri-client/tests/unit/api.test.ts b/Client/tauri-client/tests/unit/api.test.ts index 66d56533..29ca5f11 100644 --- a/Client/tauri-client/tests/unit/api.test.ts +++ b/Client/tauri-client/tests/unit/api.test.ts @@ -10,6 +10,15 @@ vi.mock("@tauri-apps/plugin-http", () => ({ fetch: mockFetch, })); +// Mock the Rust HTTP TOFU proxy so ensureHttpProxy resolves synchronously to a +// stable origin. Returning `https://{host}` keeps the URL assertions below +// unchanged — the proxy indirection is exercised by the Rust unit tests and by +// httpProxy's own tests, not here. +vi.mock("../../src/lib/httpProxy", () => ({ + ensureHttpProxy: (host: string) => Promise.resolve(`https://${host}`), + stopHttpProxy: () => Promise.resolve(), +})); + import { createApiClient, ApiClientError } from "../../src/lib/api"; function jsonResponse(data: unknown, status = 200): Response { @@ -425,25 +434,19 @@ describe("API Client", () => { expect(fetchCallOpts().signal).toBe(controller.signal); }); - it("does not set danger.acceptInvalidCerts without allowSelfSigned", async () => { + it("never sets danger.acceptInvalidCerts (cert pinning is handled by the Rust proxy)", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", user: { id: 1 } })); await api.verifyTotp("123456", "pt"); const opts = fetchCallOpts(); expect((opts as Record).danger).toBeUndefined(); }); - it("sets danger.acceptInvalidCerts when allowSelfSigned is true", async () => { - const selfSignedApi = createApiClient( - { host: "localhost:8443", token: "test-token", allowSelfSigned: true }, - onUnauthorized, - ); + it("routes verify-totp through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({ token: "t", user: { id: 1 } })); - await selfSignedApi.verifyTotp("123456", "pt"); - const opts = fetchCallOpts(); - expect((opts as Record).danger).toEqual({ - acceptInvalidCerts: true, - acceptInvalidHostnames: false, - }); + await api.verifyTotp("123456", "pt"); + // The httpProxy mock resolves ensureHttpProxy("localhost:8443") to + // https://localhost:8443, so the tunneled URL matches the direct one. + expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/auth/verify-totp"); }); }); @@ -702,23 +705,16 @@ describe("API Client", () => { setTimeoutSpy.mockRestore(); }); - it("getHealth does not set danger without allowSelfSigned", async () => { + it("getHealth never sets danger (cert pinning handled by the Rust proxy)", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); await api.getHealth(); expect((fetchCallOpts() as Record).danger).toBeUndefined(); }); - it("getHealth sets danger.acceptInvalidCerts when allowSelfSigned", async () => { - const selfSignedApi = createApiClient( - { host: "localhost:8443", token: "test-token", allowSelfSigned: true }, - onUnauthorized, - ); + it("getHealth fetches through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({ status: "ok", version: "1.0.0", uptime: 0 })); - await selfSignedApi.getHealth(); - expect((fetchCallOpts() as Record).danger).toEqual({ - acceptInvalidCerts: true, - acceptInvalidHostnames: false, - }); + await api.getHealth(); + expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/health"); }); }); @@ -820,24 +816,17 @@ describe("API Client", () => { }); }); - describe("doFetch danger option", () => { - it("regular requests without allowSelfSigned do not set danger", async () => { + describe("doFetch transport", () => { + it("never sets danger — TLS trust is enforced by the Rust HTTP proxy", async () => { mockFetch.mockResolvedValue(jsonResponse({})); await api.getMe(); expect((fetchCallOpts() as Record).danger).toBeUndefined(); }); - it("requests with allowSelfSigned set danger.acceptInvalidCerts", async () => { - const selfSignedApi = createApiClient( - { host: "localhost:8443", token: "test-token", allowSelfSigned: true }, - onUnauthorized, - ); + it("fetches through the resolved proxy origin", async () => { mockFetch.mockResolvedValue(jsonResponse({})); - await selfSignedApi.getMe(); - expect((fetchCallOpts() as Record).danger).toEqual({ - acceptInvalidCerts: true, - acceptInvalidHostnames: false, - }); + await api.getMe(); + expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me"); }); }); diff --git a/Client/tauri-client/tests/unit/attachments-render.test.ts b/Client/tauri-client/tests/unit/attachments-render.test.ts index 8351523f..7e7c9dea 100644 --- a/Client/tauri-client/tests/unit/attachments-render.test.ts +++ b/Client/tauri-client/tests/unit/attachments-render.test.ts @@ -17,6 +17,13 @@ vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock, })); +// The HTTP TOFU proxy resolves a server host to a fixed loopback origin so +// server-bound fetches are cert-pinned; external URLs bypass it. +vi.mock("@lib/httpProxy", () => ({ + ensureHttpProxy: () => Promise.resolve("http://127.0.0.1:9999"), + stopHttpProxy: () => Promise.resolve(), +})); + vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); @@ -408,21 +415,17 @@ describe("fetchImageAsDataUrl — network fetch failure", () => { expect(result!.startsWith("data:image/jpeg;")).toBe(true); }); - it("uses acceptInvalidCerts for server URLs only", async () => { + it("routes server URLs through the cert-pinned proxy and external URLs directly", async () => { fetchMock.mockResolvedValue({ ok: true, headers: { get: () => "image/png" }, arrayBuffer: () => Promise.resolve(new Uint8Array([1]).buffer), }); - // Fetch a server URL + // A server URL is rewritten to the loopback proxy origin (path + query + // preserved) and carries no danger option — the proxy pins the cert. await fetchImageAsDataUrl("https://myserver.local:8443/img.png"); - expect(fetchMock).toHaveBeenCalledWith( - "https://myserver.local:8443/img.png", - expect.objectContaining({ - danger: expect.objectContaining({ acceptInvalidCerts: true }), - }), - ); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:9999/img.png"); fetchMock.mockReset(); clearAttachmentCaches(); @@ -432,8 +435,8 @@ describe("fetchImageAsDataUrl — network fetch failure", () => { arrayBuffer: () => Promise.resolve(new Uint8Array([1]).buffer), }); - // Fetch a third-party URL + // A third-party URL is fetched directly with normal TLS validation. await fetchImageAsDataUrl("https://cdn.example.com/img.png"); - expect(fetchMock).toHaveBeenCalledWith("https://cdn.example.com/img.png", {}); + expect(fetchMock).toHaveBeenCalledWith("https://cdn.example.com/img.png"); }); }); diff --git a/Client/tauri-client/tests/unit/http-proxy.test.ts b/Client/tauri-client/tests/unit/http-proxy.test.ts new file mode 100644 index 00000000..6cfd5ff5 --- /dev/null +++ b/Client/tauri-client/tests/unit/http-proxy.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +import { ensureHttpProxy, stopHttpProxy } from "../../src/lib/httpProxy"; + +describe("ensureHttpProxy", () => { + beforeEach(() => { + invokeMock.mockReset(); + // Clear per-host cache between tests by stopping any previously started host. + return stopHttpProxy("cache.example:8443").then(() => invokeMock.mockReset()); + }); + + it("starts a tunnel and returns the loopback origin", async () => { + invokeMock.mockResolvedValue(51234); + const origin = await ensureHttpProxy("host-a.example:8443"); + expect(origin).toBe("http://127.0.0.1:51234"); + expect(invokeMock).toHaveBeenCalledWith("start_http_proxy", { + remoteHost: "host-a.example:8443", + }); + }); + + it("caches the origin per host (one start per host)", async () => { + invokeMock.mockResolvedValue(40000); + const a = await ensureHttpProxy("host-b.example:8443"); + const b = await ensureHttpProxy("host-b.example:8443"); + expect(a).toBe(b); + expect(invokeMock).toHaveBeenCalledTimes(1); + }); + + it("de-duplicates concurrent starts for the same host", async () => { + let resolvePort: (p: number) => void = () => {}; + invokeMock.mockReturnValue(new Promise((r) => (resolvePort = r))); + const p1 = ensureHttpProxy("host-c.example:8443"); + const p2 = ensureHttpProxy("host-c.example:8443"); + resolvePort(45000); + const [o1, o2] = await Promise.all([p1, p2]); + expect(o1).toBe("http://127.0.0.1:45000"); + expect(o2).toBe("http://127.0.0.1:45000"); + expect(invokeMock).toHaveBeenCalledTimes(1); + }); + + it("stopHttpProxy invokes stop and drops the cache so a restart re-invokes", async () => { + invokeMock.mockResolvedValue(46000); + await ensureHttpProxy("host-d.example:8443"); + await stopHttpProxy("host-d.example:8443"); + expect(invokeMock).toHaveBeenCalledWith("stop_http_proxy", { + remoteHost: "host-d.example:8443", + }); + + invokeMock.mockReset(); + invokeMock.mockResolvedValue(46001); + const origin = await ensureHttpProxy("host-d.example:8443"); + expect(origin).toBe("http://127.0.0.1:46001"); + expect(invokeMock).toHaveBeenCalledWith("start_http_proxy", { + remoteHost: "host-d.example:8443", + }); + }); +}); diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index 5b3285c3..bf576058 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -15,7 +15,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | ID | Sev | Finding | Status | |----|-----|---------|--------| | A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | DECIDED 2026-07-19 — implement end-to-end (D1) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | -| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | DECIDED 2026-07-19 — next security work: TOFU HTTP proxy in Rust (D5) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | +| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | CLOSED 2026-07-19 — HTTP TOFU proxy implemented (`http_proxy.rs` + `httpProxy.ts`); REST path now cert-pinned, `acceptInvalidCerts` removed | | A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies | | A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope | | A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | DECIDED 2026-07-19 — adopt sqlc as the real query layer (D2) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | @@ -138,7 +138,7 @@ Findings target structure and one security gap. | ID | Sev | Area | Evidence | Finding | Recommendation | Effort | |----|-----|------|----------|---------|----------------|--------| -| A-2026-07-02 | HIGH | Security | `src/main.ts` (`allowSelfSigned: true` at API-client construction); `src-tauri` http plugin built with `dangerous-settings` | Every REST call accepts any certificate. The WS and LiveKit paths pin TOFU fingerprints in Rust; the HTTP path — which carries the auth token on every request — does not. An active MITM can capture tokens without triggering the cert-mismatch modal. | Implement the acknowledged fix: a TOFU HTTP proxy in Rust (mirror `ws_proxy.rs`), or route REST through a pinned Rust command. Until then this is the client's weakest transport link. | M | +| A-2026-07-02 | HIGH | Security | `src/main.ts` (`allowSelfSigned: true` at API-client construction); `src-tauri` http plugin built with `dangerous-settings` | Every REST call accepts any certificate. The WS and LiveKit paths pin TOFU fingerprints in Rust; the HTTP path — which carries the auth token on every request — does not. An active MITM can capture tokens without triggering the cert-mismatch modal. | **FIXED 2026-07-19** — TOFU HTTP proxy (`src-tauri/src/http_proxy.rs` + `src/lib/httpProxy.ts`) tunnels REST through a cert-pinned loopback; `allowSelfSigned`/`acceptInvalidCerts` and the `dangerous-settings` feature removed. | M | | A-2026-07-12 | MEDIUM | Coherence | `src/components/solid/` (154 LOC), `src/lib/solidMount.ts`, `src/lib/solidAdapter.ts`, `vite-plugin-solid` config; CHANGELOG "Solid.js migration (abandoned)" | The abandoned migration's beachhead, adapters, build plugin, and test deps remain, and `docs/client-architecture.md` (2026-03-30) still describes a SolidJS client. Two mental models for contributors, one of them false. | Delete the beachhead + adapters + build plugin; replace `client-architecture.md` content with a pointer to [architecture/client.md](architecture/client.md) or a rewrite. | S | | — | MEDIUM | Maintainability | `src/lib/livekitSession.ts` (1,719 LOC); `AccountTab.ts` (845), `SidebarArea.ts` (812), `LoginForm.ts` (699) | `livekitSession.ts` owns the connection state machine, E2EE, track management, reconnection, and diagnostics in one class. It is the highest-risk file to modify in the client. | Extract E2EE (already has `e2eeCrypto.ts` as a seam) and track management into collaborators; keep the state machine as the core. Settle for splitting the settings tabs opportunistically. | M | | — | MEDIUM | State | `src/stores/voice.store.ts` imports members/auth stores; `auth.store.clearAuth()` reaches into `leaveVoice()` + notification cleanup; business logic in `main.ts` subscribers | Cross-store singleton coupling: teardown ordering lives implicitly in import graphs and bootstrap subscribers. | Introduce a thin session-lifecycle module (login/logout orchestration) that calls stores, so stores stop calling each other. | M | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 24a6aa90..443e9965 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -18,7 +18,7 @@ here (and the audit's closure table) as items land. | D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | Planned | | D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) | | D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. | -| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | Design ready 2026-07-19 — see [http-tofu-proxy.md](http-tofu-proxy.md); implementation is the next work unit | +| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). | | D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. | | D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the *fresh* specs. | **Implemented 2026-07-19** — all three specs refreshed against the code (incl. E2EE protocol section, migrations 001–015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. | | D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch** — `LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. | diff --git a/docs/plans/http-tofu-proxy.md b/docs/plans/http-tofu-proxy.md index 88790107..33c085e0 100644 --- a/docs/plans/http-tofu-proxy.md +++ b/docs/plans/http-tofu-proxy.md @@ -1,9 +1,34 @@ # Client HTTP TOFU Proxy (D5) — Design -**Status:** design only, not implemented +**Status:** implemented 2026-07-19 **Decision:** D5 in [audit-2026-07-19-decisions.md](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`); 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.ts` — `ensureHttpProxy(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