Merge pull request #1186 from J3vb/claude/blueprints-architectural-audit-k927qb

Client HTTP TOFU proxy: cert-pin the REST path (closes A-2026-07-02)
This commit is contained in:
J3vb
2026-07-19 16:28:16 +02:00
committed by GitHub
15 changed files with 819 additions and 112 deletions
+6 -6
View File
@@ -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"
@@ -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:*"
}
]
},
@@ -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<HashMap<String, ProxyEntry>>,
}
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<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, HttpProxyState>,
remote_host: String,
) -> Result<u16, String> {
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<std::sync::Mutex<Option<String>>>;
/// 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<rustls::client::danger::ServerCertVerified, rustls::Error> {
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.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::client::danger::HandshakeSignatureValid, rustls::Error> {
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::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
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<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<String, String> {
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<R: Runtime>(
app: AppHandle<R>,
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<R: Runtime>(
app: AppHandle<R>,
mut local: TcpStream,
remote_host: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// ── 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::<dyn std::error::Error + Send + Sync>::from(
"HTTP request headers too large",
));
}
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
})
.await
.map_err(|_| {
Box::<dyn std::error::Error + Send + Sync>::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::<IpAddr>() {
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::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::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"));
}
}
+4
View File
@@ -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,
])
@@ -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<string> {
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<string, Promise<string | null>>();
@@ -225,18 +239,12 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
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<void> {
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}`);
+21 -33
View File
@@ -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<string> {
return `${await ensureHttpProxy(config.host)}/api/v1`;
}
function adminBaseUrl(): string {
return `https://${config.host}/admin/api`;
async function adminBaseUrl(): Promise<string> {
return `${await ensureHttpProxy(config.host)}/admin/api`;
}
function headers(): Record<string, string> {
@@ -84,15 +87,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
signal?: AbortSignal,
): Promise<T> {
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<T>;
}
function request<T>(
async function request<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("API", baseUrl(), method, path, body, signal);
return doFetch<T>("API", await baseUrl(), method, path, body, signal);
}
function adminRequest<T>(
async function adminRequest<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("Admin API", adminBaseUrl(), method, path, body, signal);
return doFetch<T>("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<AuthResponse> {
// 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<string, string> = {};
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");
}
+59
View File
@@ -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<string, string>();
/** host → in-flight start so concurrent callers don't race the tunnel. */
const pending = new Map<string, Promise<string>>();
/**
* 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<string> {
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<number>("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<void> {
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) });
}
}
+12 -1
View File
@@ -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<string> {
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);
+5 -6
View File
@@ -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();
});
+24 -35
View File
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).danger).toEqual({
acceptInvalidCerts: true,
acceptInvalidHostnames: false,
});
await api.getMe();
expect(fetchCallUrl()).toBe("https://localhost:8443/api/v1/users/me");
});
});
@@ -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");
});
});
@@ -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<number>((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",
});
});
});
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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 001015, 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. |
+26 -1
View File
@@ -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<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.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