mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
fix(mitm): add CONNECT handler with bypass/passthrough TCP support (C1, plan 11 §4.6/§12 #16)
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 `<DATA_DIR>/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.
This commit is contained in:
127
src/mitm/_internal/bypass.cjs
Normal file
127
src/mitm/_internal/bypass.cjs
Normal file
@@ -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 `<DATA_DIR>/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<string>} 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,
|
||||
};
|
||||
@@ -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] = "<path>";
|
||||
}
|
||||
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 <DATA_DIR>/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();
|
||||
|
||||
Reference in New Issue
Block a user