From bcfc87f31b854c34f406f0cd6b769f86bc9deb61 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 16:47:10 -0300 Subject: [PATCH] =?UTF-8?q?fix(mitm):=20add=20CONNECT=20handler=20with=20b?= =?UTF-8?q?ypass/passthrough=20TCP=20support=20(C1,=20plan=2011=20=C2=A74.?= =?UTF-8?q?6/=C2=A712=20#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring `src/mitm/server.cjs` into compliance with the AgentBridge MITM contract (master plan §3.5 / §12 acceptance #16). Prior to this commit the bypass/passthrough logic existed in TS (`src/mitm/passthrough.ts`, `src/mitm/targets/index.ts::routeConnection`, `src/lib/db/agentBridgeBypass.ts`) but was completely disconnected from the running CJS proxy. Changes: - Add `server.on("connect", ...)` so HTTPS proxy clients can still tunnel to non-AgentBridge hosts without losing internet. Per host the handler decides: - bypass (default regex or user glob) → raw TCP pipe, NO TLS decrypt, NO content logging (privacy: bypass = "never see content") - target (in TARGET_HOSTS) → write 200 Connection Established and emit `connection` so the existing `https.createServer` decrypts and routes via the normal flow - passthrough (anything else) → raw TCP pipe - Introduce `src/mitm/_internal/bypass.cjs` shim that mirrors `DEFAULT_BYPASS_PATTERNS` and `routeConnection` from the TS source. Defaults stay hardcoded (banks, gov, okta, auth0); user patterns load from `/mitm/bypass.json` (written by manager — separate commit). - Add a CJS port of `sanitizeErrorMessage` and wire it into the intercept error path so HTTP/SSE error bodies never expose raw `err.message`. Closes a pre-existing Hard Rule #12 violation in the file. Defaults match `src/mitm/passthrough.ts::DEFAULT_BYPASS_PATTERNS` and `shouldBypass` precedence is identical to `routeConnection`. Antigravity non-regression preserved — known hosts still trigger TLS termination via the existing request handler. --- src/mitm/_internal/bypass.cjs | 127 ++++++++++++++++++++++ src/mitm/server.cjs | 199 +++++++++++++++++++++++++++++++++- 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 src/mitm/_internal/bypass.cjs diff --git a/src/mitm/_internal/bypass.cjs b/src/mitm/_internal/bypass.cjs new file mode 100644 index 0000000000..0f0312da65 --- /dev/null +++ b/src/mitm/_internal/bypass.cjs @@ -0,0 +1,127 @@ +/** + * Bypass / passthrough routing primitives used by `src/mitm/server.cjs`. + * + * This file exists because: + * - `server.cjs` runs as a standalone CommonJS process and cannot import + * the ESM/TS source of `src/mitm/passthrough.ts` or + * `src/mitm/targets/index.ts`. + * - We still want unit tests to cover the bypass/passthrough decision + * without spawning the actual TLS server (which would require certs and + * ROUTER_API_KEY). + * + * Defaults MUST mirror `DEFAULT_BYPASS_PATTERNS` in + * `src/mitm/passthrough.ts`. User bypass patterns are produced by + * `src/mitm/manager.ts::writeBypassJson` and loaded by `server.cjs` at + * boot from `/mitm/bypass.json`. + * + * Plan reference: + * - 11-agent-bridge.plan.md §4.6 (passthrough/bypass) + * - master-plan-group-A.md §3.5 (header injection contract) + * - master-plan-group-A.md §12 #16 (passthrough acceptance criterion) + */ + +"use strict"; + +// Default bypass patterns — banks, governments, SSO providers. Must stay in +// sync with src/mitm/passthrough.ts::DEFAULT_BYPASS_PATTERNS. +const DEFAULT_BYPASS_PATTERNS = [ + /\.bank\./i, + /(^|\.)gov(\.|$)/i, + /(^|\.)okta\.com$/i, + /(^|\.)auth0\.com$/i, +]; + +/** + * Match a hostname against a simple glob pattern (only `*` wildcard). + * Linear, ReDoS-safe — mirrors `globMatch` in src/mitm/passthrough.ts. + * + * @param {string} hostname + * @param {string} pattern + * @returns {boolean} + */ +function bypassGlobMatch(hostname, pattern) { + const segments = String(pattern).toLowerCase().split("*"); + if (segments.length > 9) return false; + const h = String(hostname).toLowerCase(); + if (segments.length === 1) return h === segments[0]; + const first = segments[0]; + if (first && !h.startsWith(first)) return false; + const last = segments[segments.length - 1]; + if (last && !h.endsWith(last)) return false; + let pos = first.length; + for (let i = 1; i < segments.length - 1; i++) { + const seg = segments[i]; + if (seg === "") continue; + const idx = h.indexOf(seg, pos); + if (idx === -1) return false; + pos = idx + seg.length; + } + if (last) { + const minEnd = pos + last.length; + if (minEnd > h.length) return false; + } + return true; +} + +/** + * Decide what to do with a connection (CONNECT or direct TLS) to `hostname`. + * Returns one of: + * - "bypass": tunnel without TLS decrypt; never log body/headers. + * - "target": hostname matches the AgentBridge target set — proceed with + * local TLS termination. + * - "passthrough": neither bypass nor target — transparent TCP tunnel. + * + * Precedence matches `src/mitm/targets/index.ts::routeConnection`: + * bypass > target > passthrough + * + * @param {string} hostname + * @param {Iterable} targetHosts - set/array of known target hosts + * @param {string[]} userBypassPatterns - lowercased user glob strings + * @returns {"bypass" | "target" | "passthrough"} + */ +function routeBypass(hostname, targetHosts, userBypassPatterns) { + if (!hostname) return "passthrough"; + const h = String(hostname).toLowerCase(); + if (DEFAULT_BYPASS_PATTERNS.some((re) => re.test(h))) return "bypass"; + const patterns = Array.isArray(userBypassPatterns) ? userBypassPatterns : []; + if (patterns.some((p) => bypassGlobMatch(h, p))) return "bypass"; + // targetHosts may be a Set, an array, or any iterable with `.has` semantics. + if (targetHosts && typeof targetHosts.has === "function") { + if (targetHosts.has(h)) return "target"; + } else if (Array.isArray(targetHosts)) { + if (targetHosts.includes(h)) return "target"; + } + return "passthrough"; +} + +/** + * Parse a `bypass.json` file content blob into an array of lowercased user + * glob patterns. Pure function — no fs I/O — so this shim stays free of any + * path-traversal surface (CWE-22). The actual file read lives in + * `server.cjs`, which knows the trusted, pre-resolved file path. + * + * Returns [] when the input is missing or malformed — the proxy must keep + * working even when the user has never customized the bypass list. + * + * @param {string} raw - file contents (utf-8 JSON) or empty string + * @returns {string[]} + */ +function parseBypassJson(raw) { + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.patterns)) return []; + return parsed.patterns + .filter((p) => typeof p === "string" && p.length > 0) + .map((p) => p.toLowerCase()); + } catch { + return []; + } +} + +module.exports = { + DEFAULT_BYPASS_PATTERNS, + bypassGlobMatch, + routeBypass, + parseBypassJson, +}; diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index 4c6ac93b61..a22428b098 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -1,4 +1,5 @@ const https = require("https"); +const net = require("net"); const fs = require("fs"); const path = require("path"); const dns = require("dns"); @@ -84,6 +85,81 @@ if (_dynamicAdded > 0) { console.log(`[MITM] Loaded ${_dynamicAdded} additional host(s) from targets.json`); } +// ========================================================================= +// Minimal CJS port of `sanitizeErrorMessage` from open-sse/utils/error.ts. +// Hard Rule #12: HTTP / SSE error bodies must never expose raw err.stack / +// err.message. The CJS proxy cannot import the TS ESM module, so we mirror +// the linear (ReDoS-safe) tokenizer here. +// ========================================================================= +const SANITIZE_MAX_LEN = 4096; +const SANITIZE_SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"]; +function looksLikeAbsolutePath(tok) { + if (tok.length < 4 || tok.length > 2048) return false; + const isPosix = tok.charCodeAt(0) === 0x2f; + const isWindows = + tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]); + if (!isPosix && !isWindows) return false; + const dot = tok.lastIndexOf("."); + if (dot <= 0 || dot === tok.length - 1) return false; + const ext = tok.slice(dot + 1).split(":", 1)[0].toLowerCase(); + return SANITIZE_SOURCE_EXT.includes(ext); +} +function sanitizeErrorMessage(message) { + let str = + typeof message === "string" ? message : String(message == null ? "" : message); + if (str.length > SANITIZE_MAX_LEN) str = str.slice(0, SANITIZE_MAX_LEN); + const nl = str.indexOf("\n"); + const firstLine = nl >= 0 ? str.slice(0, nl) : str; + const parts = firstLine.split(/(\s+)/); + for (let i = 0; i < parts.length; i++) { + if (looksLikeAbsolutePath(parts[i])) parts[i] = ""; + } + return parts.join(""); +} + +// ========================================================================= +// C1 — Passthrough / Bypass routing (plan 11 §4.6, master plan §3.5/§12 #16). +// +// The CJS proxy mirrors the routing logic of `src/mitm/passthrough.ts` and +// `src/mitm/targets/index.ts::routeConnection` so CONNECT tunnels for hosts +// that aren't AgentBridge targets and aren't on the user bypass list still +// get a transparent TCP forward (no TLS decrypt). Defaults live in the +// `_internal/bypass.cjs` shim (also used by unit tests). The user list lives +// in /mitm/bypass.json written by `manager.writeBypassJson()`. +// ========================================================================= + +const bypassShim = require("./_internal/bypass.cjs"); + +const BYPASS_JSON_FILE = path.join(DATA_DIR, "mitm", "bypass.json"); +let _userBypassPatterns = []; // array of glob strings, lowercased + +function loadUserBypassPatterns() { + try { + if (!fs.existsSync(BYPASS_JSON_FILE)) { + _userBypassPatterns = []; + return 0; + } + const raw = fs.readFileSync(BYPASS_JSON_FILE, "utf-8"); + _userBypassPatterns = bypassShim.parseBypassJson(raw); + return _userBypassPatterns.length; + } catch (err) { + console.error(`[MITM] Failed to load bypass.json: ${err.message}`); + _userBypassPatterns = []; + return 0; + } +} + +function routeBypass(hostname) { + return bypassShim.routeBypass(hostname, TARGET_HOSTS, _userBypassPatterns); +} + +const _bypassLoaded = loadUserBypassPatterns(); +if (_bypassLoaded > 0) { + console.log( + `[MITM] Loaded ${_bypassLoaded} user bypass pattern(s) from bypass.json` + ); +} + let _sqliteDb = null; // Toggle logging (set true to enable file logging for debugging) @@ -324,9 +400,18 @@ async function intercept(req, res, bodyBuffer, mappedModel) { res.write(decoder.decode(value, { stream: true })); } } catch (error) { + // Log the raw message locally (server console only) but never expose it + // in the response body. Hard Rule #12 — sanitize before sending. console.error(`❌ ${error.message}`); if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + res.end( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(error && error.message), + type: "mitm_error", + }, + }) + ); } } @@ -375,6 +460,118 @@ const server = https.createServer(sslOptions, async (req, res) => { return intercept(req, res, bodyBuffer, mappedModel); }); +// ========================================================================= +// C1 — CONNECT handler: bypass + passthrough TCP support (plan 11 §4.6). +// +// Clients (browsers, IDE agents acting as HTTP proxy clients) send a +// CONNECT request before opening a TLS tunnel. The original `https.Server` +// has no built-in CONNECT handler because it expects connections to come +// pre-routed (typically via /etc/hosts DNS spoofing). For AgentBridge we +// also accept clients configured with HTTPS_PROXY/HTTP_PROXY, where every +// HTTPS request arrives as CONNECT. For those: +// +// - bypass hostname → raw TCP pipe upstream, NO TLS decrypt, NO log of +// body or headers. Privacy contract: bypass = "never see content". +// - passthrough (host not in TARGET_HOSTS, no bypass match) → raw TCP +// pipe upstream so the user's system never loses internet for hosts +// outside our scope. Acceptance criterion §12 #16. +// - target hostname → write 200 Connection Established and pipe the +// client socket into the local TLS-terminating port so the existing +// https.createServer can decrypt and route via the normal flow. +// +// Note: in the DNS-spoof mode (IDE points at 127.0.0.1 via /etc/hosts), +// IDEs reach the server directly without CONNECT; the existing +// `https.createServer` request handler still applies for those. The +// CONNECT handler only fires for clients that explicitly speak proxy. +// ========================================================================= + +function parseConnectAuthority(authority) { + // CONNECT host[:port] + const idx = authority.lastIndexOf(":"); + if (idx === -1) return { host: authority.toLowerCase(), port: 443 }; + const host = authority.slice(0, idx).toLowerCase(); + const port = Number.parseInt(authority.slice(idx + 1), 10); + return { + host, + port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443, + }; +} + +function rawTcpForward(clientSocket, head, host, port, label) { + const targetSocket = net.connect(port, host, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head && head.length > 0) targetSocket.write(head); + targetSocket.pipe(clientSocket); + clientSocket.pipe(targetSocket); + }); + + // Best-effort cleanup; never crash the proxy on tunnel errors. + const onErr = (label2) => (err) => { + console.error(`[MITM] ${label} TCP forward ${label2} error: ${err.message}`); + try { + clientSocket.destroy(); + } catch { + // ignore + } + try { + targetSocket.destroy(); + } catch { + // ignore + } + }; + targetSocket.on("error", onErr("upstream")); + clientSocket.on("error", onErr("client")); + clientSocket.on("close", () => { + try { + targetSocket.destroy(); + } catch { + // ignore + } + }); + targetSocket.on("close", () => { + try { + clientSocket.destroy(); + } catch { + // ignore + } + }); +} + +server.on("connect", (req, clientSocket, head) => { + const authority = String(req.url || ""); + const { host: connectHost, port: connectPort } = parseConnectAuthority(authority); + + const decision = routeBypass(connectHost); + + if (decision === "bypass") { + // Privacy: bypass hosts are never logged with body/headers and never + // TLS-decrypted. Only the hostname appears in console output. + console.log(`[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`); + rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass"); + return; + } + + if (decision === "target") { + // Hand the tunnel off to the local TLS-terminating server so the existing + // https.createServer request handler can decrypt and route. We write the + // 200 response ourselves and then `emit("connection")` so the TLS layer + // picks the socket up. + console.log( + `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)` + ); + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head && head.length > 0) clientSocket.unshift(head); + server.emit("connection", clientSocket); + return; + } + + // decision === "passthrough" + console.log( + `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)` + ); + rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough"); +}); + server.listen(LOCAL_PORT, () => { stats.startedAt = new Date().toISOString(); writeStats();