Files
OwnCord/Client/tauri-client/tests/unit/http-proxy.test.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

65 lines
2.5 KiB
TypeScript

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",
});
});
});