mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
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:
committed by
GitHub
parent
027af5a92f
commit
8e5e25099e
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
50
tests/unit/mitm-server-loop-guard.test.ts
Normal file
50
tests/unit/mitm-server-loop-guard.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user