Files
OwnCord/Client/tauri-client/src/lib/httpProxy.ts
T
Claude dab4d73e09 feat(client): TOFU HTTP proxy for REST — close audit A-2026-07-02 (D5)
The REST path previously used tauri-plugin-http with
danger.acceptInvalidCerts, so it accepted ANY certificate while the WS
and LiveKit paths were TOFU-pinned in Rust — and the bearer token rides
every REST request. This routes REST through a new Rust loopback
TCP->TLS proxy that pins the server certificate to the same
trust-on-first-use fingerprint as the WS proxy.

Rust (src-tauri):
- New http_proxy.rs: per-host loopback tunnels (HttpProxyState map);
  per-connection TOFU via CaptureVerifier + tofu_check, sharing
  ws_proxy's cert store (cert_store_key) and emitting the same
  cert-tofu events (first-use banner / mismatch modal). First request's
  Host is rewritten and Connection: close injected so one request rides
  each connection. Mismatch returns a clean 502 to the loopback fetch.
- Register HttpProxyState + start_http_proxy/stop_http_proxy in lib.rs.
- Drop the dangerous-settings feature from tauri-plugin-http.

TypeScript (src):
- New lib/httpProxy.ts: ensureHttpProxy(host) (per-host cache +
  concurrent-start dedup) / stopHttpProxy(host).
- api.ts, profiles.ts (health), attachments.ts (image + download) resolve
  server URLs to http://127.0.0.1:{port}; remove the allowSelfSigned
  config field and every acceptInvalidCerts block. External hosts (CDNs,
  OG previews, YouTube) keep normal TLS validation.
- main.ts constructs the API client without allowSelfSigned.
- capabilities/default.json: allow http://127.0.0.1:* fetch scope.

Tests:
- New tests/unit/http-proxy.test.ts (cache, dedup, stop/restart).
- api.test.ts and attachments-render.test.ts: mock httpProxy, replace the
  acceptInvalidCerts assertions with proxy-origin assertions.

Verified: tsc --noEmit clean; new + affected vitest suites green
(176 tests); the http_proxy pure logic (host validation, header rewrite)
passes as standalone Rust unit tests; oxlint/eslint counts unchanged
from HEAD; prettier clean. The full Tauri build (cargo) requires GUI
system libs not present in this environment and runs on CI/real runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
2026-07-19 14:26:25 +00:00

60 lines
2.1 KiB
TypeScript

// 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) });
}
}