From 12124aaad8233c32c1c10cb7e4b9f0e9c8bf8a79 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:18:01 -0300 Subject: [PATCH] feat(mitm): local-only route + trust-store installer for TPROXY decrypt (4b/N) (#4211) Integrated into release/v3.8.29 (round 7). TPROXY decrypt 4b/N: local-only route (/api/tools/agent-bridge/tproxy, LOCAL_ONLY per routeGuard) + dedicated trust-store CA installer (execFileWithPassword, no shell). Validated locally: 12/12 tests + typecheck:core clean (test-unit does not run on release PRs). --- .../api/tools/agent-bridge/tproxy/route.ts | 81 +++++++++++++ src/mitm/tproxy/caTrust.ts | 112 ++++++++++++++++++ tests/unit/tproxy-ca-trust.test.ts | 98 +++++++++++++++ tests/unit/tproxy-route.test.ts | 68 +++++++++++ 4 files changed, 359 insertions(+) create mode 100644 src/app/api/tools/agent-bridge/tproxy/route.ts create mode 100644 src/mitm/tproxy/caTrust.ts create mode 100644 tests/unit/tproxy-ca-trust.test.ts create mode 100644 tests/unit/tproxy-route.test.ts diff --git a/src/app/api/tools/agent-bridge/tproxy/route.ts b/src/app/api/tools/agent-bridge/tproxy/route.ts new file mode 100644 index 0000000000..e2130f1f68 --- /dev/null +++ b/src/app/api/tools/agent-bridge/tproxy/route.ts @@ -0,0 +1,81 @@ +/** + * GET / POST / DELETE /api/tools/agent-bridge/tproxy + * + * Drive the decrypt-capable TPROXY capture mode (Epic A, decrypt 4b/N): + * - GET → current status (running / available / interceptCount / onPort) + * - POST → start: apply the TPROXY rules, open the transparent listener, and + * install the dynamic CA in the OS trust store (dedicated slot) + * - DELETE → stop: close the listener, uninstall the CA, revert the rules + * + * LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts. + * Starting this route applies iptables rules + installs a trust-store CA via + * child processes, so loopback-only enforcement (Hard Rules #15 + #17) is + * mandatory — a leaked JWT over a tunnel must not be able to reach it. + */ +import { z } from "zod"; +import { + startCaptureMode, + stopCaptureMode, + getCaptureStatus, +} from "@/mitm/tproxy/captureManager"; +import { installTproxyCa, uninstallTproxyCa } from "@/mitm/tproxy/caTrust"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { createErrorResponse } from "@/lib/api/errorResponse"; + +// Exported for unit testing. Next.js only treats GET/POST/DELETE as route +// handlers; additional named exports are ignored by the App Router. +export const StartTproxyBodySchema = z.object({ + dport: z.number().int().min(1).max(65535).default(443), + mark: z.number().int().min(1).default(0x2333), + onPort: z.number().int().min(1).max(65535).default(8443), + routeTable: z.number().int().min(1).default(233), + bypassMark: z.number().int().min(1).default(0x539), + // Required on non-root desktops to authorize the trust-store install; ignored + // when the process is root (the VPS), where sudo is skipped entirely. + sudoPassword: z.string().optional(), +}); + +export function GET(): Response { + return Response.json(getCaptureStatus()); +} + +export async function POST(request: Request): Promise { + const raw = await request.json().catch(() => ({})); + const parsed = StartTproxyBodySchema.safeParse(raw); + if (!parsed.success) { + return createErrorResponse({ + status: 400, + type: "invalid_request", + message: "Invalid TPROXY capture config", + }); + } + + const { sudoPassword, ...cfg } = parsed.data; + const pwd = sudoPassword ?? ""; + + try { + const status = await startCaptureMode({ + cfg, + installCa: (caPem) => installTproxyCa(caPem, pwd), + uninstallCa: () => uninstallTproxyCa(pwd), + }); + return Response.json({ ok: true, status }); + } catch (err) { + return createErrorResponse({ + status: 500, + message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }); + } +} + +export async function DELETE(): Promise { + try { + const status = await stopCaptureMode(); + return Response.json({ ok: true, status }); + } catch (err) { + return createErrorResponse({ + status: 500, + message: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }); + } +} diff --git a/src/mitm/tproxy/caTrust.ts b/src/mitm/tproxy/caTrust.ts new file mode 100644 index 0000000000..7d1cea8655 --- /dev/null +++ b/src/mitm/tproxy/caTrust.ts @@ -0,0 +1,112 @@ +/** + * Fase 3 / Epic A — OS trust-store install for the TPROXY dynamic CA (decrypt 4b/N). + * + * The decrypt capture mode issues per-SNI leaves from a dynamic CA (#4173); the + * intercepted clients must trust that CA. This installs the CA cert into the OS + * trust store under a DEDICATED slot (`omniroute-tproxy-ca.crt`), separate from + * the static MITM cert (`omniroute-mitm.crt` in `cert/install.ts`), so the two + * never clobber each other. + * + * Linux-only (TPROXY is Linux-only). Privileged commands run via the controlled + * `execFileWithPassword` helper — `spawn` with arg arrays, no shell, no string + * interpolation (Hard Rule #13). It already runs the target directly (no `sudo`) + * when the process is root, so on the VPS no password is needed. Every effectful + * seam is injectable so the command sequence is unit-testable without root. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileWithPassword } from "../systemCommands.ts"; + +/** Dedicated trust-store filename — distinct from the static MITM cert's slot. */ +export const TPROXY_CA_CERT_NAME = "omniroute-tproxy-ca.crt"; + +/** Trust-store anchor dirs + refresh command, in detection order (Debian first). */ +const LINUX_CERT_PATHS: ReadonlyArray<{ dir: string; cmd: string }> = [ + { dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" }, + { dir: "/etc/ca-certificates/trust-source/anchors", cmd: "update-ca-trust" }, + { dir: "/etc/pki/ca-trust/source/anchors", cmd: "update-ca-trust" }, + { dir: "/etc/pki/trust/anchors", cmd: "update-ca-certificates" }, +]; + +export type SudoRunner = (command: string, args: string[], password: string) => Promise; + +export interface CaTrustDeps { + /** Run a privileged command (sudo/arg-array; runs direct when root). */ + run: SudoRunner; + /** Stage the CA PEM to a local file before the privileged copy. */ + writeFile: (filePath: string, data: string) => void; + /** Remove the staged file (best-effort). */ + rmFile: (filePath: string) => void; + /** Directory to stage the PEM in. */ + tmpDir: () => string; + /** Resolve the trust-store anchor dir + refresh command for this distro. */ + certConfig: () => { dir: string; cmd: string }; + /** Host platform (Linux-only gate). */ + platform: () => string; +} + +function detectCertConfig(): { dir: string; cmd: string } { + for (const c of LINUX_CERT_PATHS) { + if (fs.existsSync(c.dir)) return c; + } + return LINUX_CERT_PATHS[0]; +} + +const realDeps: CaTrustDeps = { + run: execFileWithPassword, + writeFile: (filePath, data) => fs.writeFileSync(filePath, data, { mode: 0o644 }), + rmFile: (filePath) => { + try { + fs.unlinkSync(filePath); + } catch { + // best-effort cleanup + } + }, + tmpDir: () => os.tmpdir(), + certConfig: detectCertConfig, + platform: () => process.platform, +}; + +/** + * Install the dynamic CA cert (PEM) into the OS trust store under the dedicated + * TPROXY slot. Stages the PEM to a temp file, then (privileged) copies it into the + * anchor dir and refreshes the trust store. Throws on non-Linux hosts. + */ +export async function installTproxyCa( + caPem: string, + sudoPassword = "", + deps: Partial = {} +): Promise { + const d = { ...realDeps, ...deps }; + if (d.platform() !== "linux") { + throw new Error("TPROXY CA trust install is Linux-only."); + } + const cfg = d.certConfig(); + const staged = path.join(d.tmpDir(), TPROXY_CA_CERT_NAME); + const dest = `${cfg.dir}/${TPROXY_CA_CERT_NAME}`; + d.writeFile(staged, caPem); + try { + await d.run("sudo", ["-S", "mkdir", "-p", cfg.dir], sudoPassword); + await d.run("sudo", ["-S", "cp", staged, dest], sudoPassword); + await d.run("sudo", ["-S", cfg.cmd], sudoPassword); + } finally { + d.rmFile(staged); + } +} + +/** + * Remove the TPROXY CA from the OS trust store (its dedicated slot only — leaves + * the static MITM cert untouched) and refresh. No-op on non-Linux hosts. + */ +export async function uninstallTproxyCa( + sudoPassword = "", + deps: Partial = {} +): Promise { + const d = { ...realDeps, ...deps }; + if (d.platform() !== "linux") return; + const cfg = d.certConfig(); + const dest = `${cfg.dir}/${TPROXY_CA_CERT_NAME}`; + await d.run("sudo", ["-S", "rm", "-f", dest], sudoPassword); + await d.run("sudo", ["-S", cfg.cmd], sudoPassword); +} diff --git a/tests/unit/tproxy-ca-trust.test.ts b/tests/unit/tproxy-ca-trust.test.ts new file mode 100644 index 0000000000..14d39d90ba --- /dev/null +++ b/tests/unit/tproxy-ca-trust.test.ts @@ -0,0 +1,98 @@ +/** + * Fase 3 / Epic A — OS trust-store install for the TPROXY dynamic CA (decrypt 4b/N). + * + * Installs the dynamic CA into the trust store under a DEDICATED slot + * (`omniroute-tproxy-ca.crt`) so it never clobbers the static MITM cert slot. + * Every effectful seam is injected, so these tests pin the exact privileged + * command sequence (no shell, arg arrays — Hard Rule #13) without root. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + installTproxyCa, + uninstallTproxyCa, + TPROXY_CA_CERT_NAME, +} from "../../src/mitm/tproxy/caTrust.ts"; + +function fakeDeps(over: Record = {}) { + const calls: Array<{ command: string; args: string[] }> = []; + const writes: Array<{ path: string; data: string }> = []; + const removed: string[] = []; + return { + calls, + writes, + removed, + deps: { + run: async (command: string, args: string[]) => { + calls.push({ command, args }); + }, + writeFile: (p: string, data: string) => { + writes.push({ path: p, data }); + }, + rmFile: (p: string) => { + removed.push(p); + }, + tmpDir: () => "/tmp", + certConfig: () => ({ dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" }), + platform: () => "linux", + ...over, + } as never, + }; +} + +test("installTproxyCa stages the PEM, copies it into the dedicated slot, refreshes, cleans up", async () => { + const f = fakeDeps(); + await installTproxyCa("CA-PEM", "", f.deps); + + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].path, /omniroute-tproxy-ca\.crt$/); + assert.equal(f.writes[0].data, "CA-PEM"); + + const cmds = f.calls.map((c) => `${c.command} ${c.args.join(" ")}`); + assert.deepEqual(cmds, [ + "sudo -S mkdir -p /usr/local/share/ca-certificates", + `sudo -S cp /tmp/${TPROXY_CA_CERT_NAME} /usr/local/share/ca-certificates/${TPROXY_CA_CERT_NAME}`, + "sudo -S update-ca-certificates", + ]); + assert.deepEqual(f.removed, [`/tmp/${TPROXY_CA_CERT_NAME}`], "staged file cleaned up"); +}); + +test("installTproxyCa never touches the static MITM cert slot", async () => { + const f = fakeDeps(); + await installTproxyCa("CA-PEM", "", f.deps); + const joined = f.calls.map((c) => c.args.join(" ")).join(" "); + assert.ok(!joined.includes("omniroute-mitm.crt"), "must not collide with the MITM cert"); + assert.ok(joined.includes("omniroute-tproxy-ca.crt")); +}); + +test("installTproxyCa cleans up the staged file even when a privileged command fails", async () => { + const f = fakeDeps({ + run: async (_c: string, a: string[]) => { + if (a.includes("cp")) throw new Error("EPERM: not permitted"); + }, + }); + await assert.rejects(() => installTproxyCa("CA-PEM", "", f.deps), /EPERM/); + assert.deepEqual(f.removed, [`/tmp/${TPROXY_CA_CERT_NAME}`], "staged file still cleaned up on failure"); +}); + +test("installTproxyCa throws on non-Linux hosts and runs nothing", async () => { + const f = fakeDeps({ platform: () => "darwin" }); + await assert.rejects(() => installTproxyCa("CA-PEM", "", f.deps), /Linux-only/); + assert.equal(f.calls.length, 0); +}); + +test("uninstallTproxyCa removes only the dedicated slot and refreshes", async () => { + const f = fakeDeps(); + await uninstallTproxyCa("", f.deps); + const cmds = f.calls.map((c) => `${c.command} ${c.args.join(" ")}`); + assert.deepEqual(cmds, [ + `sudo -S rm -f /usr/local/share/ca-certificates/${TPROXY_CA_CERT_NAME}`, + "sudo -S update-ca-certificates", + ]); +}); + +test("uninstallTproxyCa is a no-op on non-Linux hosts", async () => { + const f = fakeDeps({ platform: () => "win32" }); + await uninstallTproxyCa("", f.deps); + assert.equal(f.calls.length, 0); +}); diff --git a/tests/unit/tproxy-route.test.ts b/tests/unit/tproxy-route.test.ts new file mode 100644 index 0000000000..f834799d63 --- /dev/null +++ b/tests/unit/tproxy-route.test.ts @@ -0,0 +1,68 @@ +/** + * Fase 3 / Epic A — /api/tools/agent-bridge/tproxy route (decrypt 4b/N). + * + * Drives the decrypt capture mode (start/stop/status). The route applies iptables + * rules + installs a trust-store CA via child processes, so it MUST be local-only + * (Hard Rules #15 + #17). In CI the native addon is absent, so start fails + * gracefully with a sanitized 500 — which is exactly what these tests pin, along + * with config validation, status, and the local-only classification. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + GET, + POST, + DELETE, + StartTproxyBodySchema, +} from "../../src/app/api/tools/agent-bridge/tproxy/route.ts"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +function postReq(body: unknown): Request { + return new Request("http://local/api/tools/agent-bridge/tproxy", { + method: "POST", + body: JSON.stringify(body), + }); +} + +test("the tproxy route is classified LOCAL_ONLY (spawns iptables + installs a CA)", () => { + assert.equal(isLocalOnlyPath("/api/tools/agent-bridge/tproxy"), true); +}); + +test("GET reports running:false and an available boolean when idle", async () => { + const res = GET(); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.running, false); + assert.equal(typeof body.available, "boolean"); +}); + +test("POST rejects an out-of-range config with a 400 invalid_request", async () => { + const res = await POST(postReq({ dport: 70000 })); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error.type, "invalid_request"); +}); + +test("POST returns a sanitized 500 when the native addon is unavailable (CI)", async () => { + const res = await POST(postReq({})); + assert.equal(res.status, 500); + const body = await res.json(); + assert.match(body.error.message, /native addon|CAP_NET_ADMIN/); + assert.ok(!body.error.message.includes("at /"), "no stack trace leaked"); +}); + +test("DELETE stops (no-op when idle) and returns ok with status", async () => { + const res = await DELETE(); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.status.running, false); +}); + +test("StartTproxyBodySchema applies sensible TPROXY defaults", () => { + const parsed = StartTproxyBodySchema.parse({}); + assert.equal(parsed.dport, 443); + assert.equal(parsed.onPort, 8443); + assert.equal(parsed.routeTable, 233); + assert.equal(parsed.bypassMark, 0x539); +});