From 8e5e25099ef583d9c1c7bf925532e2ec6b30a971 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:00:41 -0300 Subject: [PATCH] feat(mitm): loop-guard self-check + verbosity control in server.cjs (Gaps 14+15) (#4101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.28 (rebased onto release — dropped the already-squash-merged #4084 commits; only the Gaps 14+15 loop-guard/verbosity delta remains) --- src/mitm/_internal/bypass.cjs | 43 +++++++++++++++++++ src/mitm/server.cjs | 40 ++++++++++++++---- tests/unit/mitm-server-loop-guard.test.ts | 50 +++++++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 tests/unit/mitm-server-loop-guard.test.ts diff --git a/src/mitm/_internal/bypass.cjs b/src/mitm/_internal/bypass.cjs index 0f0312da65..10526658c6 100644 --- a/src/mitm/_internal/bypass.cjs +++ b/src/mitm/_internal/bypass.cjs @@ -119,9 +119,52 @@ function parseBypassJson(raw) { } } +/** + * True if `ip` is an IPv4 or IPv6 loopback address. (Gap 14 helper.) + * @param {string} ip + * @returns {boolean} + */ +function isLoopbackIp(ip) { + if (typeof ip !== "string") return false; + if (ip === "::1" || ip === "::ffff:127.0.0.1") return true; + return /^127\./.test(ip); +} + +/** + * Defense-in-depth loop guard (Gap 14). The primary guard is the + * x-omniroute-source header; this is a structural backstop. If a forwarded + * request's resolved upstream is a loopback address on the MITM server's own + * listen port, dialing it would re-enter this same server — an infinite loop / + * fd storm. Callers should refuse instead of dialing themselves. + * + * @param {string} targetIp - resolved upstream IP + * @param {number} destPort - the port we would dial upstream + * @param {number} localPort - this MITM server's own listen port + * @returns {boolean} + */ +function isSelfLoopDestination(targetIp, destPort, localPort) { + return isLoopbackIp(targetIp) && Number(destPort) === Number(localPort); +} + +/** + * Parse the MITM_VERBOSE env var into a routing-decision log level (Gap 15). + * Default 1 (log decisions) preserves existing behavior; 0 silences; higher + * levels are reserved for finer detail. Garbage falls back to the default. + * + * @param {string|undefined} envValue + * @returns {number} + */ +function parseVerboseLevel(envValue) { + const n = Number.parseInt(envValue, 10); + return Number.isInteger(n) && n >= 0 ? n : 1; +} + module.exports = { DEFAULT_BYPASS_PATTERNS, bypassGlobMatch, routeBypass, parseBypassJson, + isLoopbackIp, + isSelfLoopDestination, + parseVerboseLevel, }; diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index 55a9aaf821..5c2f6a37e3 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -135,6 +135,13 @@ function sanitizeErrorMessage(message) { const bypassShim = require("./_internal/bypass.cjs"); +// Routing-decision log verbosity (Gap 15). MITM_VERBOSE=0 silences the +// per-request decision lines; default 1 preserves the previous behavior. +const VERBOSE = bypassShim.parseVerboseLevel(process.env.MITM_VERBOSE); +function vlog(level, msg) { + if (VERBOSE >= level) console.log(msg); +} + const BYPASS_JSON_FILE = path.join(DATA_DIR, "mitm", "bypass.json"); let _userBypassPatterns = []; // array of glob strings, lowercased @@ -337,6 +344,19 @@ async function passthrough(req, res, bodyBuffer) { const targetHost = getTargetHost(req); const targetIP = await resolveTargetIP(targetHost); + // Defense-in-depth loop guard (Gap 14). The x-omniroute-source header is the + // primary guard; this is a structural backstop for when it is stripped: if + // the upstream resolves to ourselves (loopback on our own listen port), + // forwarding would re-enter this server forever. Refuse instead of looping. + if (bypassShim.isSelfLoopDestination(targetIP, 443, LOCAL_PORT)) { + console.error( + `❌ Loop guard: ${targetHost} resolves to self (${targetIP}:${LOCAL_PORT}) — refusing to forward` + ); + if (!res.headersSent) res.writeHead(508); + res.end("Loop Detected"); + return; + } + // TLS validation is enabled by default. Set MITM_DISABLE_TLS_VERIFY=1 only // in controlled local environments where the target uses a self-signed cert. const rejectUnauthorized = process.env.MITM_DISABLE_TLS_VERIFY !== "1"; @@ -439,31 +459,31 @@ const server = https.createServer(sslOptions, async (req, res) => { const host = String(req.headers.host || "").split(":")[0].toLowerCase(); const model = bodyBuffer.length > 0 ? extractModel(bodyBuffer) : null; - console.log(`[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}`); + vlog(1, `[MITM] ${req.method} ${host}${req.url} | body: ${bodyBuffer.length}B | model: ${model || "N/A"}`); if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); if (req.headers["x-omniroute-source"] === "omniroute") { - console.log(`[MITM] → PASSTHROUGH (OmniRoute source loop)`); + vlog(1, `[MITM] → PASSTHROUGH (OmniRoute source loop)`); return passthrough(req, res, bodyBuffer); } if (!TARGET_HOSTS.has(host)) { - console.log(`[MITM] → PASSTHROUGH (host ${host} not in target list)`); + vlog(1, `[MITM] → PASSTHROUGH (host ${host} not in target list)`); return passthrough(req, res, bodyBuffer); } const isChatRequest = CHAT_URL_PATTERNS.some((p) => req.url.includes(p)); if (!isChatRequest) { - console.log(`[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`); + vlog(1, `[MITM] → PASSTHROUGH (URL ${req.url} does not match chat patterns)`); return passthrough(req, res, bodyBuffer); } const mappedModel = getMappedModel(model); if (!mappedModel) { - console.log(`[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`); + vlog(1, `[MITM] → PASSTHROUGH (model "${model}" has no MITM alias mapping)`); return passthrough(req, res, bodyBuffer); } @@ -471,7 +491,7 @@ const server = https.createServer(sslOptions, async (req, res) => { stats.lastInterceptAt = new Date().toISOString(); writeStats(); - console.log(`[MITM] INTERCEPTED ${model} → ${mappedModel}`); + vlog(1, `[MITM] INTERCEPTED ${model} → ${mappedModel}`); return intercept(req, res, bodyBuffer, mappedModel); }); @@ -585,7 +605,7 @@ server.on("connect", (req, clientSocket, head) => { 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)`); + vlog(1, `[MITM] CONNECT ${connectHost}:${connectPort} → BYPASS (TCP tunnel)`); rawTcpForward(clientSocket, head, connectHost, connectPort, "bypass"); return; } @@ -595,7 +615,8 @@ server.on("connect", (req, clientSocket, head) => { // 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( + vlog( + 1, `[MITM] CONNECT ${connectHost}:${connectPort} → TARGET (TLS terminate locally)` ); clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); @@ -605,7 +626,8 @@ server.on("connect", (req, clientSocket, head) => { } // decision === "passthrough" - console.log( + vlog( + 1, `[MITM] CONNECT ${connectHost}:${connectPort} → PASSTHROUGH (TCP tunnel)` ); rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough"); diff --git a/tests/unit/mitm-server-loop-guard.test.ts b/tests/unit/mitm-server-loop-guard.test.ts new file mode 100644 index 0000000000..8b8cbd74fd --- /dev/null +++ b/tests/unit/mitm-server-loop-guard.test.ts @@ -0,0 +1,50 @@ +/** + * Gap 14 + Gap 15: structural loop guard + verbosity level, both living in the + * testable `_internal/bypass.cjs` shim that server.cjs consumes. + * + * Gap 14 — the primary loop guard is the x-omniroute-source header; this is a + * defense-in-depth backstop: if a forwarded request's resolved upstream is a + * loopback address on the MITM's own listen port, dialing it re-enters this + * server (infinite loop / fd storm). Detect and refuse. + * + * Gap 15 — MITM_VERBOSE controls how chatty the routing-decision log is. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const requireCjs = createRequire(import.meta.url); +const shim = requireCjs("../../src/mitm/_internal/bypass.cjs") as { + isSelfLoopDestination: (ip: string, destPort: number, localPort: number) => boolean; + parseVerboseLevel: (env: string | undefined) => number; +}; + +test("isSelfLoopDestination — loopback IPv4 on the listen port loops", () => { + assert.equal(shim.isSelfLoopDestination("127.0.0.1", 443, 443), true); +}); + +test("isSelfLoopDestination — any 127.x.x.x on the listen port loops", () => { + assert.equal(shim.isSelfLoopDestination("127.0.0.53", 443, 443), true); +}); + +test("isSelfLoopDestination — IPv6 loopback on the listen port loops", () => { + assert.equal(shim.isSelfLoopDestination("::1", 443, 443), true); +}); + +test("isSelfLoopDestination — a real public IP never loops", () => { + assert.equal(shim.isSelfLoopDestination("1.2.3.4", 443, 443), false); +}); + +test("isSelfLoopDestination — loopback on a DIFFERENT port does not loop", () => { + assert.equal(shim.isSelfLoopDestination("127.0.0.1", 8080, 443), false); +}); + +test("parseVerboseLevel — defaults to 1 (log decisions) when unset/garbage", () => { + assert.equal(shim.parseVerboseLevel(undefined), 1); + assert.equal(shim.parseVerboseLevel("not-a-number"), 1); +}); + +test("parseVerboseLevel — honors explicit levels including 0 (silent)", () => { + assert.equal(shim.parseVerboseLevel("0"), 0); + assert.equal(shim.parseVerboseLevel("2"), 2); +});