diff --git a/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts new file mode 100644 index 0000000000..6a873df386 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts @@ -0,0 +1,87 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/http-proxy + * + * Start or stop the HTTP_PROXY listener (default port 8080). + * `EADDRINUSE` is surfaced as 409 with a structured error body. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorCaptureModeActionSchema } from "@/shared/schemas/inspector"; +import { startHttpProxyServer } from "@/mitm/inspector/httpProxyServer"; +import { getHttpProxyHandle, setHttpProxyHandle } from "@/lib/inspector/captureState"; + +const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorCaptureModeActionSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { action } = parsed.data; + + if (action === "stop") { + const handle = getHttpProxyHandle(); + if (!handle) { + return Response.json({ ok: true, running: false, port: null }); + } + try { + await handle.stop(); + setHttpProxyHandle(null); + return Response.json({ ok: true, running: false, port: null }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to stop HTTP proxy")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + } + + // action === "start" + const existing = getHttpProxyHandle(); + if (existing) { + return Response.json({ ok: true, running: true, port: existing.port }); + } + + try { + const handle = await startHttpProxyServer(DEFAULT_PORT); + setHttpProxyHandle(handle); + return Response.json({ ok: true, running: true, port: handle.port }, { status: 201 }); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr?.code === "EADDRINUSE") { + return new Response( + JSON.stringify({ + error: { + message: `Port ${DEFAULT_PORT} is already in use`, + type: "conflict", + code: "EADDRINUSE", + port: DEFAULT_PORT, + }, + }), + { status: 409, headers: { "content-type": "application/json" } } + ); + } + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to start HTTP proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/route.ts new file mode 100644 index 0000000000..b792a8efdb --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/tools/traffic-inspector/capture-modes + * + * Returns the current status of all 4 capture modes: + * 1. agentBridge — always active when the MITM server is running + * 2. customHosts — count from DB + * 3. httpProxy — running flag + port + * 4. systemProxy — applied flag + guardUntil + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { listCustomHosts } from "@/lib/db/inspectorCustomHosts"; +import { + getHttpProxyHandle, + getSystemProxyState, + isTlsInterceptEnabled, +} from "@/lib/inspector/captureState"; + +export async function GET(): Promise { + try { + const customHosts = listCustomHosts(); + const httpProxy = getHttpProxyHandle(); + const systemProxy = getSystemProxyState(); + + return Response.json({ + agentBridge: true, + customHosts: { + count: customHosts.length, + enabledCount: customHosts.filter((h) => h.enabled).length, + }, + httpProxy: { + running: httpProxy !== null, + port: httpProxy?.port ?? null, + }, + systemProxy: { + applied: systemProxy.applied, + guardUntil: systemProxy.guardUntil, + port: systemProxy.port, + }, + tlsIntercept: { + enabled: isTlsInterceptEnabled(), + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to get capture mode status")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts new file mode 100644 index 0000000000..0f4866d65f --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts @@ -0,0 +1,92 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/system-proxy + * + * Apply or revert the OS-level system proxy. + * + * `apply` — sets the system proxy to 127.0.0.1: and saves the + * prior state so it can be restored. Starts a guard timer that + * auto-reverts after `guardMinutes` (default 30). + * + * `revert` — restores the previously saved proxy state. + * + * Hard Rule #13: all shell invocations happen in `systemProxyConfig.ts` using + * `execFile` with array args — no interpolation here. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSystemProxyActionSchema } from "@/shared/schemas/inspector"; +import { apply, revert } from "@/mitm/inspector/systemProxyConfig"; +import { + getSystemProxyState, + setSystemProxyApplied, + clearSystemProxy, +} from "@/lib/inspector/captureState"; + +const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080; +const DEFAULT_GUARD_MINUTES = Number( + process.env.INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES ?? "30" +) || 30; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorSystemProxyActionSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { action, port, guardMinutes } = parsed.data; + const resolvedPort = port ?? DEFAULT_PORT; + const resolvedGuard = guardMinutes ?? DEFAULT_GUARD_MINUTES; + + if (action === "revert") { + const state = getSystemProxyState(); + const previousState = state.previousState; + + try { + if (previousState) { + await revert(previousState); + } + clearSystemProxy(); + return Response.json({ ok: true, applied: false }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to revert system proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } + } + + // action === "apply" + try { + const result = await apply(resolvedPort); + setSystemProxyApplied(resolvedPort, result.previousState, resolvedGuard); + return Response.json({ + ok: true, + applied: true, + port: resolvedPort, + platform: result.platform, + guardUntil: getSystemProxyState().guardUntil, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to apply system proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts new file mode 100644 index 0000000000..43334ea563 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts @@ -0,0 +1,39 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/tls-intercept + * + * Toggle TLS body decryption in the MITM proxy. When enabled, the MITM + * server decrypts HTTPS bodies and the Traffic Inspector can show full + * request/response content. When disabled, CONNECT tunnels are passed through + * and only metadata is captured. + * + * State is held in the `captureState` module (process-lifetime). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorTlsInterceptToggleSchema } from "@/shared/schemas/inspector"; +import { isTlsInterceptEnabled, setTlsIntercept } from "@/lib/inspector/captureState"; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorTlsInterceptToggleSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + setTlsIntercept(parsed.data.enabled); + return Response.json({ ok: true, tlsIntercept: { enabled: isTlsInterceptEnabled() } }); +} diff --git a/src/app/api/tools/traffic-inspector/export.har/route.ts b/src/app/api/tools/traffic-inspector/export.har/route.ts new file mode 100644 index 0000000000..93e972fd35 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/export.har/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/tools/traffic-inspector/export.har + * + * Exports the entire (optionally filtered) traffic buffer as a HAR v1.2 file. + * The Content-Disposition header triggers a browser download. + * + * Secrets are always masked in the export — see `toHar` implementation. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorListQuerySchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import { toHar } from "@/lib/inspector/harExport"; +import type { ListFilters } from "@/mitm/inspector/types"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const rawQuery: Record = {}; + url.searchParams.forEach((value, key) => { + rawQuery[key] = value; + }); + + const parsed = InspectorListQuerySchema.safeParse(rawQuery); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const filters: ListFilters = { + profile: parsed.data.profile, + host: parsed.data.host, + agent: parsed.data.agent as ListFilters["agent"], + status: parsed.data.status, + source: parsed.data.source, + sessionId: parsed.data.sessionId, + }; + + try { + const requests = globalTrafficBuffer.list(filters); + const har = toHar(requests); + const json = JSON.stringify(har, null, 2); + + return new Response(json, { + status: 200, + headers: { + "content-type": "application/json", + "content-disposition": 'attachment; filename="traffic.har"', + "cache-control": "no-store", + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts b/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts new file mode 100644 index 0000000000..5619ce972c --- /dev/null +++ b/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts @@ -0,0 +1,77 @@ +/** + * DELETE /api/tools/traffic-inspector/hosts/[host] — remove a custom host + * PATCH /api/tools/traffic-inspector/hosts/[host] — toggle enabled flag + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { z } from "zod"; +import { removeCustomHost, toggleCustomHost, listCustomHosts } from "@/lib/db/inspectorCustomHosts"; + +interface Params { + params: Promise<{ host: string }>; +} + +const PatchBodySchema = z.object({ + enabled: z.boolean(), +}); + +export async function DELETE(_request: Request, { params }: Params): Promise { + const { host } = await params; + const decodedHost = decodeURIComponent(host); + + try { + removeCustomHost(decodedHost); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to remove host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function PATCH(request: Request, { params }: Params): Promise { + const { host } = await params; + const decodedHost = decodeURIComponent(host); + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = PatchBodySchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + toggleCustomHost(decodedHost, parsed.data.enabled); + // Return updated record + const hosts = listCustomHosts(); + const updated = hosts.find((h) => h.host === decodedHost); + if (!updated) { + return new Response(JSON.stringify(buildErrorBody(404, "Host not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return Response.json(updated); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to toggle host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/hosts/route.ts b/src/app/api/tools/traffic-inspector/hosts/route.ts new file mode 100644 index 0000000000..607f358527 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/hosts/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/tools/traffic-inspector/hosts — list custom host capture entries + * POST /api/tools/traffic-inspector/hosts — add a host (DB record) + * + * The DB record enables the MITM proxy to SNI-certify the host on demand. + * DNS /etc/hosts edits are out of scope for this route — clients that need + * OS-level redirect must use the Custom Hosts setup guide (requires sudo). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorCustomHostSchema } from "@/shared/schemas/inspector"; +import { listCustomHosts, addCustomHost } from "@/lib/db/inspectorCustomHosts"; + +export async function GET(): Promise { + try { + const hosts = listCustomHosts(); + return Response.json({ hosts }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list hosts")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorCustomHostSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { host, kind, label } = parsed.data; + + try { + addCustomHost(host, kind, label ?? undefined); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to add host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + return Response.json({ ok: true, host }, { status: 201 }); +} diff --git a/src/app/api/tools/traffic-inspector/internal/ingest/route.ts b/src/app/api/tools/traffic-inspector/internal/ingest/route.ts new file mode 100644 index 0000000000..cf47212a1f --- /dev/null +++ b/src/app/api/tools/traffic-inspector/internal/ingest/route.ts @@ -0,0 +1,127 @@ +/** + * POST /api/tools/traffic-inspector/internal/ingest + * + * Internal endpoint consumed by `server.cjs` (D4 fallback) to push + * intercepted request data into the traffic buffer when the request does + * NOT pass through a TypeScript handler that already calls + * `agentBridgeHook.ts`. + * + * Security model (double LOCAL_ONLY): + * 1. `isLocalOnlyPath("/api/tools/traffic-inspector/")` blocks all non- + * loopback callers unconditionally — this is handled by the authz pipeline. + * 2. The shared secret `INSPECTOR_INTERNAL_INGEST_TOKEN` (set in .env or + * auto-generated at process boot) must match the `Authorization: Bearer` + * header. This prevents any other loopback process from stuffing the buffer. + * + * Body: partial `InterceptedRequest` — only `id`, `timestamp`, `method`, + * `host`, `path` are required; all other fields default. + * + * LOCAL_ONLY enforced by routeGuard + token gate below. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; +import { InterceptedRequestSchema } from "@/mitm/inspector/types"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +// ── Token management ──────────────────────────────────────────────────────── + +let _cachedToken: string | null = null; + +function getIngestToken(): string { + if (_cachedToken) return _cachedToken; + const env = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN; + if (env && env.length >= 16) { + _cachedToken = env; + } else { + // Auto-generate on first call; persists for the lifetime of the process. + _cachedToken = randomUUID().replace(/-/g, ""); + } + return _cachedToken; +} + +function tokenMatches(received: string): boolean { + const expected = getIngestToken(); + if (!received || !expected) return false; + try { + const a = createHash("sha256").update(expected).digest(); + const b = createHash("sha256").update(received).digest(); + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +// ── Partial schema (only required fields; rest optional) ─────────────────── + +const IngestBodySchema = InterceptedRequestSchema.partial().required({ + id: true, + timestamp: true, + method: true, + host: true, + path: true, + source: true, + requestHeaders: true, + requestSize: true, + responseHeaders: true, + responseSize: true, + status: true, +}); + +// ── Handler ───────────────────────────────────────────────────────────────── + +export async function POST(request: Request): Promise { + // Token gate (second layer after LOCAL_ONLY IP check). + const authHeader = request.headers.get("authorization") ?? ""; + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!tokenMatches(token)) { + return new Response(JSON.stringify(buildErrorBody(403, "Invalid or missing ingest token")), { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = IngestBodySchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + // Fill in any missing optional fields with sensible defaults. + const req = { + requestBody: null, + responseBody: null, + ...parsed.data, + }; + globalTrafficBuffer.push(req); + return Response.json({ ok: true, id: req.id }, { status: 200 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Ingest failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +/** + * Expose the auto-generated token for use by `server.cjs` bootstrap. + * Called once at process start via dynamic import. + */ +export function getIngestTokenForBootstrap(): string { + return getIngestToken(); +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts new file mode 100644 index 0000000000..935c503818 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts @@ -0,0 +1,60 @@ +/** + * PUT /api/tools/traffic-inspector/requests/[id]/annotation + * + * Attaches or replaces a free-text annotation on a buffered entry. + * Mutations are broadcast to all WS subscribers via `buffer.update`. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorAnnotationPutSchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function PUT(request: Request, { params }: Params): Promise { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorAnnotationPutSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify( + buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error") + ), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + const updated = { ...entry, annotation: parsed.data.annotation }; + globalTrafficBuffer.update(id, updated); + return Response.json(updated); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update annotation")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts new file mode 100644 index 0000000000..1b5253e3b3 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts @@ -0,0 +1,61 @@ +/** + * POST /api/tools/traffic-inspector/requests/[id]/replay + * + * Re-issues the captured request through the local OmniRoute instance and + * returns the response body. The replay will itself appear in the traffic + * buffer (captured by agentBridgeHook or httpProxyServer depending on path). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128"; + +export async function POST(_request: Request, { params }: Params): Promise { + const { id } = await params; + const entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + const url = `${OMNIROUTE_BASE}${entry.path}`; + + const replayHeaders: Record = { + "content-type": "application/json", + "x-omniroute-source": "inspector-replay", + }; + // Forward original Authorization if present (masked in buffer — skip if masked) + const origAuth = entry.requestHeaders["authorization"] ?? entry.requestHeaders["Authorization"]; + if (origAuth && !origAuth.includes("***")) { + replayHeaders["authorization"] = origAuth; + } + + try { + const upstream = await fetch(url, { + method: entry.method, + headers: replayHeaders, + body: entry.requestBody ?? undefined, + }); + + const body = await upstream.text(); + return new Response(body, { + status: upstream.status, + headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(502, msg || "Replay failed")), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/route.ts new file mode 100644 index 0000000000..d299e7ab3d --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/tools/traffic-inspector/requests/[id] — fetch a single intercepted request + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + const entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return Response.json(entry); +} diff --git a/src/app/api/tools/traffic-inspector/requests/route.ts b/src/app/api/tools/traffic-inspector/requests/route.ts new file mode 100644 index 0000000000..cd237e585c --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/route.ts @@ -0,0 +1,44 @@ +/** + * GET /api/tools/traffic-inspector/requests — list buffer with optional filters + * DELETE /api/tools/traffic-inspector/requests — clear the entire buffer + * + * LOCAL_ONLY enforced by routeGuard (no extra check needed here). + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorListQuerySchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import type { ListFilters } from "@/mitm/inspector/types"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const rawQuery: Record = {}; + url.searchParams.forEach((value, key) => { + rawQuery[key] = value; + }); + + const parsed = InspectorListQuerySchema.safeParse(rawQuery); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const filters: ListFilters = { + profile: parsed.data.profile, + host: parsed.data.host, + agent: parsed.data.agent as ListFilters["agent"], + status: parsed.data.status, + source: parsed.data.source, + sessionId: parsed.data.sessionId, + }; + + const requests = globalTrafficBuffer.list(filters); + return Response.json({ requests, total: requests.length }); +} + +export async function DELETE(): Promise { + globalTrafficBuffer.clear(); + return new Response(null, { status: 204 }); +} diff --git a/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts b/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts new file mode 100644 index 0000000000..ba5301f168 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/tools/traffic-inspector/sessions/[id]/export.har + * + * Export all requests of a specific session as HAR v1.2. + * Secrets are always masked — see `toHar`. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getSession, getSessionRequests } from "@/lib/db/inspectorSessions"; +import { toHar } from "@/lib/inspector/harExport"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + const rows = getSessionRequests(id); + const requests: InterceptedRequest[] = rows + .map((r) => { + try { + return JSON.parse(r.payload) as InterceptedRequest; + } catch { + return null; + } + }) + .filter((r): r is InterceptedRequest => r !== null); + + const har = toHar(requests); + const sessionName = (session.name ?? `session-${id}`).replace(/[^a-z0-9_-]/gi, "_"); + const filename = `${sessionName}.har`; + + return new Response(JSON.stringify(har, null, 2), { + status: 200, + headers: { + "content-type": "application/json", + "content-disposition": `attachment; filename="${filename}"`, + "cache-control": "no-store", + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts b/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts new file mode 100644 index 0000000000..a8c752d9d6 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts @@ -0,0 +1,123 @@ +/** + * GET /api/tools/traffic-inspector/sessions/[id] — session detail + requests + * PATCH /api/tools/traffic-inspector/sessions/[id] — stop or rename + * DELETE /api/tools/traffic-inspector/sessions/[id] — delete + cascade requests + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSessionPatchSchema } from "@/shared/schemas/inspector"; +import { + getSession, + getSessionRequests, + stopSession, + renameSession, + deleteSession, +} from "@/lib/db/inspectorSessions"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + + try { + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + const requests = getSessionRequests(id).map((r) => { + try { + return JSON.parse(r.payload) as unknown; + } catch { + return r.payload; + } + }); + return Response.json({ session, requests }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to get session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function PATCH(request: Request, { params }: Params): Promise { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorSessionPatchSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + if (parsed.data.action === "stop") { + stopSession(id); + } else if (parsed.data.action === "rename") { + if (!parsed.data.name) { + return new Response( + JSON.stringify(buildErrorBody(400, "name is required for rename action")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + renameSession(id, parsed.data.name); + } + return Response.json(getSession(id)); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function DELETE(_request: Request, { params }: Params): Promise { + const { id } = await params; + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + deleteSession(id); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to delete session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/sessions/route.ts b/src/app/api/tools/traffic-inspector/sessions/route.ts new file mode 100644 index 0000000000..470cc486fc --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/tools/traffic-inspector/sessions — list all sessions + * POST /api/tools/traffic-inspector/sessions — start a new recording session + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSessionStartSchema } from "@/shared/schemas/inspector"; +import { listSessions, createSession } from "@/lib/db/inspectorSessions"; + +export async function GET(): Promise { + try { + const sessions = listSessions(); + return Response.json({ sessions }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list sessions")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + // Empty body is valid — name is optional + body = {}; + } + + const parsed = InspectorSessionStartSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + const session = createSession({ name: parsed.data.name }); + return Response.json(session, { status: 201 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to create session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/ws/route.ts b/src/app/api/tools/traffic-inspector/ws/route.ts new file mode 100644 index 0000000000..b32a5d7cc5 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/ws/route.ts @@ -0,0 +1,142 @@ +/** + * WebSocket endpoint for the Traffic Inspector live stream. + * + * Clients connect here to receive real-time `WsEvent` frames + * (snapshot, new, update, clear) from the `globalTrafficBuffer`. + * + * LOCAL_ONLY enforcement happens unconditionally in the authz pipeline via + * `isLocalOnlyPath("/api/tools/traffic-inspector/")` — this route does not + * need to repeat that check. The WS upgrade uses the raw socket injected by + * Next.js / the standalone server. + * + * Protocol: + * 1. Client connects with `Upgrade: websocket`. + * 2. Server immediately emits `{type:"snapshot", data:[...]}`. + * 3. Subsequent mutations produce `{type:"new"|"update"|"clear", data?}`. + * 4. Server sends ping frames every 30s; client may pong (ignored here). + * 5. Closing the connection removes the subscriber. + */ + +import { createHash } from "node:crypto"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const PING_INTERVAL_MS = 30_000; + +function acceptKey(clientKey: string): string { + return createHash("sha1") + .update(clientKey + WS_GUID) + .digest("base64"); +} + +function encodeWsFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer { + const length = payload.length; + let header: Buffer; + if (length < 126) { + header = Buffer.allocUnsafe(2); + header[1] = length; + } else if (length <= 0xffff) { + header = Buffer.allocUnsafe(4); + header[1] = 126; + header.writeUInt16BE(length, 2); + } else { + header = Buffer.allocUnsafe(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + header[0] = 0x80 | (opcode & 0x0f); + return Buffer.concat([header, payload]); +} + +function sendText(socket: import("node:net").Socket, data: unknown): void { + try { + const json = JSON.stringify(data); + const payload = Buffer.from(json, "utf8"); + socket.write(encodeWsFrame(0x01, payload)); + } catch { + // socket may be destroyed; ignore + } +} + +function sendClose(socket: import("node:net").Socket): void { + try { + socket.write(encodeWsFrame(0x08)); + socket.end(); + } catch { + // already closed + } +} + +export async function GET(request: Request): Promise { + const upgrade = request.headers.get("upgrade"); + if (!upgrade || upgrade.toLowerCase() !== "websocket") { + return new Response(JSON.stringify(buildErrorBody(426, "Upgrade Required")), { + status: 426, + headers: { "content-type": "application/json", Upgrade: "websocket" }, + }); + } + + const clientKey = request.headers.get("sec-websocket-key"); + if (!clientKey) { + return new Response(JSON.stringify(buildErrorBody(400, "Missing Sec-WebSocket-Key")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + // @ts-expect-error — Next.js standalone server exposes the raw socket via + // `request.socket` but the Request type does not declare it. + const socket = (request as unknown as { socket?: import("node:net").Socket }).socket; + if (!socket) { + return new Response(JSON.stringify(buildErrorBody(500, "WebSocket upgrade unavailable")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + const acceptHeader = acceptKey(clientKey); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptHeader}`, + "\r\n", + ].join("\r\n") + ); + + const unsubscribe = globalTrafficBuffer.subscribe((ev) => { + sendText(socket, ev); + }); + + const pingTimer = setInterval(() => { + try { + socket.write(encodeWsFrame(0x09)); // ping + } catch { + cleanup(); + } + }, PING_INTERVAL_MS); + + function cleanup(): void { + clearInterval(pingTimer); + unsubscribe(); + try { + socket.destroy(); + } catch { + // already gone + } + } + + socket.once("close", cleanup); + socket.once("error", cleanup); + + // Never resolve — the socket is the response channel. + await new Promise((resolve) => { + socket.once("close", resolve); + socket.once("error", resolve); + }); + + cleanup(); + return new Response(null, { status: 101 }); +} diff --git a/src/lib/inspector/captureState.ts b/src/lib/inspector/captureState.ts new file mode 100644 index 0000000000..eda6c29bf5 --- /dev/null +++ b/src/lib/inspector/captureState.ts @@ -0,0 +1,90 @@ +/** + * Runtime state for Traffic Inspector capture modes. + * + * Held in module-level variables (process-singleton). Survives across route + * handler calls for the lifetime of the process. + * + * Exported mutation functions are the single write path so all route handlers + * stay stateless. + */ + +import type { HttpProxyServerHandle } from "@/mitm/inspector/httpProxyServer"; +import type { PreviousState } from "@/mitm/inspector/systemProxyConfig"; + +// ── HTTP Proxy ────────────────────────────────────────────────────────────── + +let httpProxyHandle: HttpProxyServerHandle | null = null; + +export function getHttpProxyHandle(): HttpProxyServerHandle | null { + return httpProxyHandle; +} + +export function setHttpProxyHandle(handle: HttpProxyServerHandle | null): void { + httpProxyHandle = handle; +} + +// ── System Proxy ──────────────────────────────────────────────────────────── + +interface SystemProxyState { + applied: boolean; + port: number | null; + guardUntil: string | null; // ISO 8601 + previousState: PreviousState | null; +} + +let systemProxyState: SystemProxyState = { + applied: false, + port: null, + guardUntil: null, + previousState: null, +}; + +let guardTimer: ReturnType | null = null; + +export function getSystemProxyState(): Readonly { + return { ...systemProxyState }; +} + +export function setSystemProxyApplied( + port: number, + previousState: PreviousState, + guardMinutes: number +): void { + if (guardTimer) clearTimeout(guardTimer); + + const guardUntil = new Date(Date.now() + guardMinutes * 60_000).toISOString(); + systemProxyState = { applied: true, port, guardUntil, previousState }; + + guardTimer = setTimeout( + () => { + // Auto-revert after guard period — fire-and-forget. + // Import lazily to avoid circular deps at module load. + import("@/mitm/inspector/systemProxyConfig").then(({ revert }) => { + const ps = systemProxyState.previousState; + systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null }; + if (ps) revert(ps).catch(() => {/* best-effort */}); + }).catch(() => {/* best-effort */}); + }, + guardMinutes * 60_000 + ); +} + +export function clearSystemProxy(): void { + if (guardTimer) { + clearTimeout(guardTimer); + guardTimer = null; + } + systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null }; +} + +// ── TLS Intercept ─────────────────────────────────────────────────────────── + +let tlsInterceptEnabled = process.env.INSPECTOR_TLS_INTERCEPT === "true"; + +export function isTlsInterceptEnabled(): boolean { + return tlsInterceptEnabled; +} + +export function setTlsIntercept(enabled: boolean): void { + tlsInterceptEnabled = enabled; +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 6f49a3fe49..5cf979986b 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -32,6 +32,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass "/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17) + "/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17) ]; /** @@ -53,6 +54,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/services/", // T-10: can run npm install + spawn node processes "/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17) + "/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17) ]; /** diff --git a/tests/integration/traffic-inspector-capture-modes.test.ts b/tests/integration/traffic-inspector-capture-modes.test.ts new file mode 100644 index 0000000000..bf9b46c7a2 --- /dev/null +++ b/tests/integration/traffic-inspector-capture-modes.test.ts @@ -0,0 +1,258 @@ +/** + * Integration tests: Traffic Inspector capture-modes endpoints + * + * Tests: + * - GET /capture-modes — status overview + * - POST /capture-modes/http-proxy — start/stop (ephemeral port to avoid 8080 conflict) + * - POST /capture-modes/system-proxy — apply/revert (mocked OS commands) + * - POST /capture-modes/tls-intercept — toggle + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-capture-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.INSPECTOR_HTTP_PROXY_PORT = "0"; // ephemeral port + +const captureModesRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/route.ts" +); +const httpProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts" +); +const systemProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts" +); +const tlsInterceptRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts" +); +const { setHttpProxyHandle, getHttpProxyHandle, clearSystemProxy } = await import( + "../../src/lib/inspector/captureState.ts" +); +const { __setExec } = await import( + "../../src/mitm/inspector/systemProxyConfig.ts" +); + +test.beforeEach(() => { + // Ensure no running proxy handle leaks between tests + const handle = getHttpProxyHandle(); + if (handle) { + handle.stop().catch(() => {/* ignore */}); + setHttpProxyHandle(null); + } + clearSystemProxy(); +}); + +test.after(() => { + // Clean up any running proxy + const handle = getHttpProxyHandle(); + if (handle) { + handle.stop().catch(() => {/* ignore */}); + setHttpProxyHandle(null); + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── GET /capture-modes ────────────────────────────────────────────────────── + +test("GET /capture-modes: returns status of all modes", async () => { + const res = await captureModesRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { + agentBridge: boolean; + httpProxy: { running: boolean; port: number | null }; + systemProxy: { applied: boolean }; + tlsIntercept: { enabled: boolean }; + }; + assert.equal(body.agentBridge, true); + assert.equal(body.httpProxy.running, false); + assert.equal(body.systemProxy.applied, false); + assert.ok("enabled" in body.tlsIntercept); +}); + +// ── POST /capture-modes/http-proxy ───────────────────────────────────────── + +test("http-proxy: start binds an ephemeral port", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "start" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { ok: boolean; running: boolean; port: number }; + assert.equal(body.ok, true); + assert.equal(body.running, true); + assert.ok(body.port > 0, "should have a bound port"); + + // Clean up + const handle = getHttpProxyHandle(); + if (handle) { + await handle.stop(); + setHttpProxyHandle(null); + } +}); + +test("http-proxy: stop when not running returns ok", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; running: boolean }; + assert.equal(body.ok, true); + assert.equal(body.running, false); +}); + +test("http-proxy: start then stop lifecycle", async () => { + const startReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "start" }), + } + ); + const startRes = await httpProxyRoute.POST(startReq); + assert.equal(startRes.status, 201); + + const stopReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + } + ); + const stopRes = await httpProxyRoute.POST(stopReq); + assert.equal(stopRes.status, 200); + const body = await stopRes.json() as { running: boolean }; + assert.equal(body.running, false); +}); + +test("http-proxy: EADDRINUSE returns 409 with structured error", async () => { + // Import startHttpProxyServer directly so we can test the low-level error path + // without depending on the module-cached DEFAULT_PORT. + const { startHttpProxyServer } = await import( + "../../src/mitm/inspector/httpProxyServer.ts" + ); + + // Occupy a random port + const blocker = net.createServer(); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + const blockedPort = (blocker.address() as net.AddressInfo).port; + + try { + // startHttpProxyServer should reject with code === EADDRINUSE + let caught: NodeJS.ErrnoException | null = null; + try { + await startHttpProxyServer(blockedPort); + } catch (err) { + caught = err as NodeJS.ErrnoException; + } + assert.ok(caught !== null, "should have thrown"); + assert.equal(caught?.code, "EADDRINUSE"); + } finally { + blocker.close(); + } +}); + +// ── POST /capture-modes/system-proxy ─────────────────────────────────────── + +test("system-proxy: apply with mocked OS commands", async () => { + const restore = __setExec(async (_file, _args) => ({ stdout: "Enabled: No\nServer: \nPort: 0", stderr: "" })); + try { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "apply", port: 8080, guardMinutes: 1 }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; applied: boolean }; + assert.equal(body.ok, true); + assert.equal(body.applied, true); + } finally { + restore(); + clearSystemProxy(); + } +}); + +test("system-proxy: revert without prior apply is a no-op", async () => { + const restore = __setExec(async (_file, _args) => ({ stdout: "", stderr: "" })); + try { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "revert" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { applied: boolean }; + assert.equal(body.applied, false); + } finally { + restore(); + } +}); + +test("system-proxy: rejects invalid action", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "invalid" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 400); +}); + +// ── POST /capture-modes/tls-intercept ────────────────────────────────────── + +test("tls-intercept: toggle on/off", async () => { + const enableReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + } + ); + const enableRes = await tlsInterceptRoute.POST(enableReq); + assert.equal(enableRes.status, 200); + const enableBody = await enableRes.json() as { tlsIntercept: { enabled: boolean } }; + assert.equal(enableBody.tlsIntercept.enabled, true); + + const disableReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + } + ); + const disableRes = await tlsInterceptRoute.POST(disableReq); + assert.equal(disableRes.status, 200); + const disableBody = await disableRes.json() as { tlsIntercept: { enabled: boolean } }; + assert.equal(disableBody.tlsIntercept.enabled, false); +}); diff --git a/tests/integration/traffic-inspector-error-sanitization.test.ts b/tests/integration/traffic-inspector-error-sanitization.test.ts new file mode 100644 index 0000000000..fd076bd3ca --- /dev/null +++ b/tests/integration/traffic-inspector-error-sanitization.test.ts @@ -0,0 +1,216 @@ +/** + * Integration tests: Traffic Inspector error sanitization + * + * Verifies that all error responses do NOT include stack traces or raw + * file paths (Hard Rule #12). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-errsanitize-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); + +const requestsRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/route.ts" +); +const requestDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/route.ts" +); +const annotationRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts" +); +const hostsRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/route.ts" +); +const hostDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/[host]/route.ts" +); +const sessionsRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/route.ts" +); +const sessionDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/route.ts" +); +const ingestRoute = await import( + "../../src/app/api/tools/traffic-inspector/internal/ingest/route.ts" +); +const httpProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts" +); +const systemProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts" +); +const tlsInterceptRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts" +); + +function noStackTrace(msg: string, label: string): void { + assert.ok( + !msg.includes("at /"), + `${label}: error message must not contain stack trace (found "at /")` + ); + assert.ok( + !msg.includes(".ts:"), + `${label}: error message must not include TS file paths` + ); +} + +async function getErrorMessage(res: Response): Promise { + const body = await res.json() as { error: { message: string } }; + return body.error?.message ?? ""; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("requests: invalid profile param does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=BAD" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "GET /requests"); +}); + +test("requests/[id]: unknown id does not leak stack", async () => { + const res = await requestDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: randomUUID() }) } + ); + assert.equal(res.status, 404); + noStackTrace(await getErrorMessage(res), "GET /requests/[id]"); +}); + +test("annotation: invalid body does not leak stack", async () => { + const entry = { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }; + globalTrafficBuffer.push(entry); + + const req = new Request("http://localhost/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: 12345 }), // wrong type + }); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "PUT annotation"); +}); + +test("hosts: invalid body does not leak stack", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "bad json!}", + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /hosts"); +}); + +test("hosts/[host] PATCH: invalid body does not leak stack", async () => { + const res = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: "bad json!", + }), + { params: Promise.resolve({ host: "foo.com" }) } + ); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "PATCH /hosts/[host]"); +}); + +test("sessions: 404 does not leak stack", async () => { + const res = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: randomUUID() }) } + ); + assert.equal(res.status, 404); + noStackTrace(await getErrorMessage(res), "GET /sessions/[id]"); +}); + +test("ingest: 403 does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer wrong-token", + }, + body: JSON.stringify({}), + } + ); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); + noStackTrace(await getErrorMessage(res), "POST /internal/ingest (403)"); +}); + +test("http-proxy: invalid action does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "invalid" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/http-proxy"); +}); + +test("system-proxy: invalid body does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "bad-action" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/system-proxy"); +}); + +test("tls-intercept: missing enabled field does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: "not-a-boolean" }), + } + ); + const res = await tlsInterceptRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/tls-intercept"); +}); diff --git a/tests/integration/traffic-inspector-hosts.test.ts b/tests/integration/traffic-inspector-hosts.test.ts new file mode 100644 index 0000000000..234eb8be61 --- /dev/null +++ b/tests/integration/traffic-inspector-hosts.test.ts @@ -0,0 +1,142 @@ +/** + * Integration tests: Traffic Inspector custom hosts CRUD + * + * Tests GET /hosts, POST /hosts, DELETE /hosts/[host], PATCH /hosts/[host]. + * DB is isolated per test via a temp DATA_DIR. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-hosts-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Boot DB so migrations run +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +const hostsRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/route.ts" +); +const hostDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/[host]/route.ts" +); + +test.beforeEach(async () => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // Re-init DB with fresh migrations + await import("../../src/lib/db/core.ts").then((m) => m.getDbInstance()); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /hosts: returns empty list initially", async () => { + const res = await hostsRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { hosts: unknown[] }; + assert.deepEqual(body.hosts, []); +}); + +test("POST /hosts: adds a host", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "api.openai.com", kind: "llm" }), + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { ok: boolean; host: string }; + assert.equal(body.ok, true); + assert.equal(body.host, "api.openai.com"); + + // Verify it appears in list + const listRes = await hostsRoute.GET(); + const list = await listRes.json() as { hosts: Array<{ host: string }> }; + assert.ok(list.hosts.some((h) => h.host === "api.openai.com")); +}); + +test("POST /hosts: rejects empty host string", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "" }), + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("POST /hosts: rejects invalid JSON", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("DELETE /hosts/[host]: removes existing host", async () => { + // Add host first + const addReq = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "remove-me.example.com", kind: "custom" }), + }); + await hostsRoute.POST(addReq); + + // Now delete it + const delRes = await hostDetailRoute.DELETE( + new Request("http://localhost/"), + { params: Promise.resolve({ host: "remove-me.example.com" }) } + ); + assert.equal(delRes.status, 204); + + // Verify gone + const listRes = await hostsRoute.GET(); + const list = await listRes.json() as { hosts: Array<{ host: string }> }; + assert.ok(!list.hosts.some((h) => h.host === "remove-me.example.com")); +}); + +test("PATCH /hosts/[host]: toggles enabled flag", async () => { + // Add host + const addReq = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "toggle-me.example.com", kind: "app", enabled: true }), + }); + await hostsRoute.POST(addReq); + + // Disable it + const patchRes = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }), + { params: Promise.resolve({ host: "toggle-me.example.com" }) } + ); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { enabled: boolean }; + assert.equal(body.enabled, false); +}); + +test("PATCH /hosts/[host]: returns 404 for non-existent host", async () => { + const res = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + { params: Promise.resolve({ host: "nonexistent.example.com" }) } + ); + assert.equal(res.status, 404); +}); diff --git a/tests/integration/traffic-inspector-internal-ingest.test.ts b/tests/integration/traffic-inspector-internal-ingest.test.ts new file mode 100644 index 0000000000..3cf2ef1048 --- /dev/null +++ b/tests/integration/traffic-inspector-internal-ingest.test.ts @@ -0,0 +1,150 @@ +/** + * Integration tests: Traffic Inspector internal ingest endpoint + * + * Tests: + * - POST without token → 403 + * - POST with wrong token → 403 + * - POST with valid token + valid body → 200 + buffer push + * - POST with valid token + invalid body → 400 (no stack trace) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-ingest-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Set a known token BEFORE importing the route so the module picks it up +const VALID_TOKEN = "test-ingest-token-abc123xyz789-longer-than-16"; +process.env.INSPECTOR_INTERNAL_INGEST_TOKEN = VALID_TOKEN; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const ingestRoute = await import( + "../../src/app/api/tools/traffic-inspector/internal/ingest/route.ts" +); + +function makeIngestRequest(token: string | null, body: unknown): Request { + const headers: Record = { + "content-type": "application/json", + }; + if (token !== null) { + headers["authorization"] = `Bearer ${token}`; + } + return new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers, + body: JSON.stringify(body), + } + ); +} + +function minimalEntry(overrides: Record = {}): Record { + return { + id: randomUUID(), + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + source: "agent-bridge", + requestHeaders: {}, + requestSize: 0, + responseHeaders: {}, + responseSize: 0, + status: 200, + ...overrides, + }; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("ingest: POST without Authorization header → 403", async () => { + const req = makeIngestRequest(null, minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("ingest: POST with wrong token → 403", async () => { + const req = makeIngestRequest("wrong-token", minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); +}); + +test("ingest: POST with empty string token → 403", async () => { + const req = makeIngestRequest("", minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); +}); + +test("ingest: POST with valid token + valid body → 200 + buffer push", async () => { + const id = randomUUID(); + const req = makeIngestRequest(VALID_TOKEN, minimalEntry({ id })); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; id: string }; + assert.equal(body.ok, true); + assert.equal(body.id, id); + + // Verify the entry was added to the buffer + const entry = globalTrafficBuffer.get(id); + assert.ok(entry, "entry should be in the buffer"); + assert.equal(entry?.host, "api.openai.com"); +}); + +test("ingest: valid token + missing required field → 400", async () => { + const req = makeIngestRequest(VALID_TOKEN, { + // missing 'host', 'path', 'source', etc. + id: randomUUID(), + timestamp: new Date().toISOString(), + method: "GET", + }); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("ingest: valid token + invalid JSON → 400", async () => { + const headers: Record = { + "content-type": "application/json", + "authorization": `Bearer ${VALID_TOKEN}`, + }; + const req = new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers, + body: "not valid json", + } + ); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("ingest: getIngestTokenForBootstrap returns a non-empty token", () => { + const token = ingestRoute.getIngestTokenForBootstrap(); + assert.ok(typeof token === "string" && token.length >= 16, "token should be ≥16 chars"); +}); + +test("ingest: multiple pushes accumulate in buffer", async () => { + const ids = [randomUUID(), randomUUID(), randomUUID()]; + for (const id of ids) { + const req = makeIngestRequest(VALID_TOKEN, minimalEntry({ id })); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 200); + } + assert.equal(globalTrafficBuffer.size(), 3); +}); diff --git a/tests/integration/traffic-inspector-localonly.test.ts b/tests/integration/traffic-inspector-localonly.test.ts new file mode 100644 index 0000000000..0fc352b51d --- /dev/null +++ b/tests/integration/traffic-inspector-localonly.test.ts @@ -0,0 +1,114 @@ +/** + * Integration tests: Traffic Inspector LOCAL_ONLY enforcement + * + * Verifies that `isLocalOnlyPath` returns true for all traffic-inspector prefixes + * and that a simulated non-loopback request to the management policy returns 403. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-local-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { isLocalOnlyPath, isLoopbackHost } = await import( + "../../src/server/authz/routeGuard.ts" +); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── isLocalOnlyPath assertions ────────────────────────────────────────────── + +test("isLocalOnlyPath: traffic-inspector prefix is LOCAL_ONLY", () => { + assert.equal( + isLocalOnlyPath("/api/tools/traffic-inspector/"), + true, + "root prefix should be LOCAL_ONLY" + ); +}); + +test("isLocalOnlyPath: ws sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/ws"), true); +}); + +test("isLocalOnlyPath: requests sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/requests"), true); +}); + +test("isLocalOnlyPath: capture-modes sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/capture-modes/http-proxy"), true); +}); + +test("isLocalOnlyPath: sessions sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true); +}); + +test("isLocalOnlyPath: internal/ingest is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/internal/ingest"), true); +}); + +test("isLocalOnlyPath: export.har is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/export.har"), true); +}); + +test("isLocalOnlyPath: hosts sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/hosts"), true); +}); + +// ── isLoopbackHost assertions ─────────────────────────────────────────────── + +test("isLoopbackHost: localhost returns true", () => { + assert.equal(isLoopbackHost("localhost"), true); +}); + +test("isLoopbackHost: 127.0.0.1 returns true", () => { + assert.equal(isLoopbackHost("127.0.0.1"), true); +}); + +test("isLoopbackHost: example.com returns false", () => { + assert.equal(isLoopbackHost("example.com"), false); +}); + +test("isLoopbackHost: external IP returns false", () => { + assert.equal(isLoopbackHost("192.168.1.100"), false); +}); + +test("isLoopbackHost: ::1 IPv6 returns true", () => { + assert.equal(isLoopbackHost("[::1]"), true); +}); + +// ── Management policy simulation ──────────────────────────────────────────── + +test("management policy: non-loopback request to LOCAL_ONLY path would be blocked", () => { + // Simulate the guard check that happens in management.ts + const path2 = "/api/tools/traffic-inspector/requests"; + const hostHeader = "example.com"; // non-loopback + + const isLocalOnly = isLocalOnlyPath(path2); + const isLoopback = isLoopbackHost(hostHeader); + + // The policy blocks when: isLocalOnly && !isLoopback + assert.equal(isLocalOnly, true, "path should be LOCAL_ONLY"); + assert.equal(isLoopback, false, "example.com should not be loopback"); + // Therefore this request would be blocked (403 LOCAL_ONLY) + const wouldBeBlocked = isLocalOnly && !isLoopback; + assert.equal(wouldBeBlocked, true, "non-loopback request to LOCAL_ONLY path should be blocked"); +}); + +test("management policy: loopback request to LOCAL_ONLY path passes IP check", () => { + const path2 = "/api/tools/traffic-inspector/ws"; + const hostHeader = "localhost"; + + const isLocalOnly = isLocalOnlyPath(path2); + const isLoopback = isLoopbackHost(hostHeader); + + assert.equal(isLocalOnly, true); + assert.equal(isLoopback, true); + const passesIpCheck = !(isLocalOnly && !isLoopback); + assert.equal(passesIpCheck, true, "loopback to LOCAL_ONLY path passes IP gate"); +}); diff --git a/tests/integration/traffic-inspector-requests.test.ts b/tests/integration/traffic-inspector-requests.test.ts new file mode 100644 index 0000000000..9f894c441f --- /dev/null +++ b/tests/integration/traffic-inspector-requests.test.ts @@ -0,0 +1,193 @@ +/** + * Integration tests: Traffic Inspector requests endpoints + * + * Tests GET /requests (with filters), DELETE /requests, GET /requests/[id], + * and PUT /requests/[id]/annotation. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-reqs-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const requestsRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/route.ts" +); +const requestDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/route.ts" +); +const annotationRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts" +); + +function makeEntry(overrides: Partial<{ + id: string; + host: string; + detectedKind: "llm" | "app" | "unknown"; + status: number | "in-flight" | "error"; + source: "agent-bridge" | "custom-host" | "http-proxy" | "system-proxy"; +}> = {}) { + return { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + detectedKind: "llm" as const, + ...overrides, + }; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /requests: returns empty list when buffer is empty", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/requests"); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.deepEqual(body.requests, []); + assert.equal(body.total, 0); +}); + +test("GET /requests: returns all entries without filter", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "a.com" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "b.com" })); + + const req = new Request("http://localhost/api/tools/traffic-inspector/requests"); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.equal(body.total, 2); +}); + +test("GET /requests: filters by profile=llm", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), detectedKind: "llm" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), detectedKind: "app" })); + + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=llm" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.equal(body.total, 1); +}); + +test("GET /requests: filters by host", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "target.com" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "other.com" })); + + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?host=target.com" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: Array<{ host: string }>; total: number }; + assert.equal(body.total, 1); + assert.equal(body.requests[0]?.host, "target.com"); +}); + +test("GET /requests: rejects invalid profile param with 400", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=invalid" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("DELETE /requests: clears the buffer", async () => { + globalTrafficBuffer.push(makeEntry()); + + const res = await requestsRoute.DELETE(); + assert.equal(res.status, 204); + assert.equal(globalTrafficBuffer.size(), 0); +}); + +test("GET /requests/[id]: returns entry by id", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request(`http://localhost/api/tools/traffic-inspector/requests/${entry.id}`); + const res = await requestDetailRoute.GET(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 200); + const body = await res.json() as { id: string }; + assert.equal(body.id, entry.id); +}); + +test("GET /requests/[id]: returns 404 for unknown id", async () => { + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${randomUUID()}` + ); + const res = await requestDetailRoute.GET(req, { + params: Promise.resolve({ id: randomUUID() }), + }); + assert.equal(res.status, 404); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("PUT /requests/[id]/annotation: attaches annotation", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${entry.id}/annotation`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: "my note" }), + } + ); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 200); + const body = await res.json() as { annotation: string }; + assert.equal(body.annotation, "my note"); + + // Confirm buffer was updated + const updated = globalTrafficBuffer.get(entry.id); + assert.equal(updated?.annotation, "my note"); +}); + +test("PUT /requests/[id]/annotation: rejects annotation > 10000 chars", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${entry.id}/annotation`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: "x".repeat(10_001) }), + } + ); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 400); +}); diff --git a/tests/integration/traffic-inspector-sessions.test.ts b/tests/integration/traffic-inspector-sessions.test.ts new file mode 100644 index 0000000000..3687b170b8 --- /dev/null +++ b/tests/integration/traffic-inspector-sessions.test.ts @@ -0,0 +1,241 @@ +/** + * Integration tests: Traffic Inspector sessions CRUD + * + * Tests the full lifecycle: POST start → PATCH stop → GET snapshot → DELETE cascade. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-sessions-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resetDbInstance, getDbInstance } = await import("../../src/lib/db/core.ts"); + +async function resetStorage() { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // Re-initialize db + getDbInstance(); +} + +const sessionsRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/route.ts" +); +const sessionDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/route.ts" +); +const sessionHarRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts" +); +const { appendSessionRequest } = await import("../../src/lib/db/inspectorSessions.ts"); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("POST /sessions: creates a session", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Test Session" }), + }); + const res = await sessionsRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { id: string; started_at: string }; + assert.ok(body.id, "should have an id"); + assert.ok(body.started_at, "should have started_at"); +}); + +test("POST /sessions: name is optional", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await sessionsRoute.POST(req); + assert.equal(res.status, 201); +}); + +test("GET /sessions: lists all sessions", async () => { + // Create two sessions + await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "s1" }), + }) + ); + await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "s2" }), + }) + ); + + const res = await sessionsRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { sessions: unknown[] }; + assert.equal(body.sessions.length, 2); +}); + +test("PATCH /sessions/[id]: stop adds ended_at", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + const session = await createRes.json() as { id: string }; + + const patchReq = new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + }); + const patchRes = await sessionDetailRoute.PATCH(patchReq, { + params: Promise.resolve({ id: session.id }), + }); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { ended_at: string | null }; + assert.ok(body.ended_at !== null, "ended_at should be set after stop"); +}); + +test("PATCH /sessions/[id]: rename updates name", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "old-name" }), + }) + ); + const session = await createRes.json() as { id: string }; + + const patchRes = await sessionDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "rename", name: "new-name" }), + }), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { name: string }; + assert.equal(body.name, "new-name"); +}); + +test("GET /sessions/[id]: returns session with requests", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "with-reqs" }), + }) + ); + const session = await createRes.json() as { id: string }; + + // Append a fake request + const payload = JSON.stringify({ + id: randomUUID(), + source: "agent-bridge", + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + status: 200, + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + timestamp: new Date().toISOString(), + }); + appendSessionRequest(session.id, payload); + + const getRes = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(getRes.status, 200); + const body = await getRes.json() as { session: { id: string }; requests: unknown[] }; + assert.equal(body.session.id, session.id); + assert.equal(body.requests.length, 1); +}); + +test("DELETE /sessions/[id]: cascades requests", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + const session = await createRes.json() as { id: string }; + + appendSessionRequest(session.id, JSON.stringify({ note: "test" })); + + const delRes = await sessionDetailRoute.DELETE( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(delRes.status, 204); + + // Session should be gone + const getRes = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(getRes.status, 404); +}); + +test("GET /sessions/[id]/export.har: returns HAR file", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "har-test" }), + }) + ); + const session = await createRes.json() as { id: string }; + + const reqPayload = { + id: randomUUID(), + source: "agent-bridge", + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + status: 200, + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + timestamp: new Date().toISOString(), + }; + appendSessionRequest(session.id, JSON.stringify(reqPayload)); + + const harRes = await sessionHarRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(harRes.status, 200); + assert.ok( + harRes.headers.get("content-disposition")?.includes(".har"), + "should have .har filename" + ); + const har = await harRes.json() as { log: { entries: unknown[] } }; + assert.ok(har.log, "should be a HAR object"); + assert.equal(har.log.entries.length, 1); +}); diff --git a/tests/integration/traffic-inspector-ws.test.ts b/tests/integration/traffic-inspector-ws.test.ts new file mode 100644 index 0000000000..8bd12cad5c --- /dev/null +++ b/tests/integration/traffic-inspector-ws.test.ts @@ -0,0 +1,148 @@ +/** + * Integration tests: Traffic Inspector WebSocket endpoint + * + * Tests WS upgrade, initial snapshot delivery, and live buffer events. + * We do not spin up a full HTTP server — we test the buffer subscribe + * mechanism directly since the WS handler is a thin wrapper around it. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-ws-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.INSPECTOR_BUFFER_SIZE = "100"; + +const { TrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const wsRoute = await import( + "../../src/app/api/tools/traffic-inspector/ws/route.ts" +); + +function makeRequest(upgrade = "websocket", clientKey = "dGhlIHNhbXBsZSBub25jZQ=="): Request { + return new Request("http://localhost/api/tools/traffic-inspector/ws", { + headers: { + upgrade, + "sec-websocket-key": clientKey, + connection: "Upgrade", + }, + }); +} + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("ws/route: rejects non-WebSocket GET with 426", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/ws"); + const res = await wsRoute.GET(req); + assert.equal(res.status, 426); + const body = await res.json() as { error: { message: string } }; + assert.ok(body.error.message.includes("Upgrade"), "should mention upgrade"); +}); + +test("ws/route: rejects missing Sec-WebSocket-Key with 400", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/ws", { + headers: { upgrade: "websocket", connection: "Upgrade" }, + }); + const res = await wsRoute.GET(req); + assert.equal(res.status, 400); +}); + +test("ws/route: rejects when no raw socket available with 500", async () => { + const req = makeRequest(); + // No `.socket` property injected — Next.js standalone would attach it + const res = await wsRoute.GET(req); + assert.equal(res.status, 500); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("TrafficBuffer: subscribe receives snapshot immediately", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const entry = { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }; + buf.push(entry); + + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + + assert.equal(events.length, 1, "should receive snapshot immediately"); + const snapshot = events[0] as { type: string; data: unknown[] }; + assert.equal(snapshot.type, "snapshot"); + assert.ok(Array.isArray(snapshot.data)); + assert.equal(snapshot.data.length, 1); + + unsub(); +}); + +test("TrafficBuffer: push broadcasts new event to subscribers", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + + // snapshot is at index 0 + buf.push({ + id: randomUUID(), + source: "http-proxy" as const, + timestamp: new Date().toISOString(), + method: "GET", + host: "example.com", + path: "/", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }); + + assert.equal(events.length, 2, "should have snapshot + new event"); + const newEv = events[1] as { type: string; data: { host: string } }; + assert.equal(newEv.type, "new"); + assert.equal(newEv.data.host, "example.com"); + + unsub(); +}); + +test("TrafficBuffer: unsubscribe stops receiving events", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + unsub(); + + buf.push({ + id: randomUUID(), + source: "system-proxy" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "test.com", + path: "/api", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 204 as const, + }); + + assert.equal(events.length, 1, "should only have the initial snapshot"); +});