feat(mitm): loop-guard self-check + verbosity control in server.cjs (Gaps 14+15) (#4101)

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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 16:00:41 -03:00
committed by GitHub
parent 027af5a92f
commit 8e5e25099e
3 changed files with 124 additions and 9 deletions

View File

@@ -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,
};

View File

@@ -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");

View File

@@ -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);
});