fiemwl / src /mitm /inspector /httpProxyServer.ts
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
9.01 kB
/**
* HTTP_PROXY listener for the Traffic Inspector.
*
* Accepts HTTP_PROXY=http://127.0.0.1:8080 style upstream traffic. Two paths:
*
* 1. HTTP direct (non-CONNECT): the proxy reads the request body, forwards
* it via `fetch()`, captures the response, and records the full exchange.
* 2. CONNECT (TLS tunnel): the proxy opens a raw TCP bridge so HTTPS still
* works, but only metadata (host:port) is captured — bodies stay opaque.
* The buffer entry carries a `note` field explaining why.
*
* `EADDRINUSE` during `listen()` rejects the returned promise so callers can
* surface a clean error to the user. See master-plan §3.12 + plan 12 §4.2.6.
*/
import http from "node:http";
import net from "node:net";
import { randomUUID } from "node:crypto";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { sanitizeHeaders } from "../sanitizeHeaders.ts";
import { maskSecret } from "../maskSecrets.ts";
import { globalTrafficBuffer } from "./buffer.ts";
import type { InterceptedRequest } from "./types.ts";
const DEFAULT_PORT = parseEnvNumber(process.env.INSPECTOR_HTTP_PROXY_PORT, 8080);
function parseEnvNumber(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}
export interface HttpProxyServerHandle {
port: number;
server: http.Server;
stop(): Promise<void>;
}
/**
* Build a sanitized Headers object suitable for an upstream `fetch()`.
* Drops hop-by-hop fields via the existing denylist and coerces array values.
*/
function buildFetchHeaders(raw: http.IncomingHttpHeaders): Record<string, string> {
// sanitizeHeaders applies upstream denylist + masks Authorization for buffer
// logging; here we want denylist only (so the upstream still sees the auth
// header). Reuse sanitizeHeaders with a separate masking pass for the buffer.
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(raw)) {
if (value === undefined || value === null) continue;
const lower = name.toLowerCase();
// Skip hop-by-hop / framing — same names sanitizeHeaders also drops.
if (
lower === "host" ||
lower === "connection" ||
lower === "keep-alive" ||
lower === "proxy-authenticate" ||
lower === "proxy-authorization" ||
lower === "te" ||
lower === "trailer" ||
lower === "transfer-encoding" ||
lower === "upgrade" ||
lower === "content-length"
) {
continue;
}
out[lower] = Array.isArray(value) ? value.join(", ") : String(value);
}
return out;
}
function safeUrl(rawUrl: string | undefined, hostHeader: string | undefined): URL | null {
if (!rawUrl) return null;
try {
if (/^https?:\/\//i.test(rawUrl)) return new URL(rawUrl);
if (hostHeader) return new URL(`http://${hostHeader}${rawUrl}`);
} catch {
return null;
}
return null;
}
async function readBody(req: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
const startedAt = performance.now();
const intercepted: InterceptedRequest = {
id: randomUUID(),
source: "http-proxy",
timestamp: new Date().toISOString(),
method: req.method ?? "GET",
host: req.headers.host ?? "",
path: req.url ?? "/",
requestHeaders: sanitizeHeaders(req.headers),
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: "in-flight",
};
globalTrafficBuffer.push(intercepted);
void (async () => {
try {
const target = safeUrl(req.url, req.headers.host);
if (!target) {
throw new Error("Invalid request URL");
}
intercepted.host = target.host;
intercepted.path = target.pathname + target.search;
const body = await readBody(req);
intercepted.requestSize = body.length;
intercepted.requestBody = body.length > 0 ? maskSecret(body.toString("utf8")) : null;
const upstreamHeaders = buildFetchHeaders(req.headers);
const upstream = await fetch(target.toString(), {
method: req.method ?? "GET",
headers: upstreamHeaders,
body: body.length > 0 ? body : undefined,
redirect: "manual",
});
const respBuf = Buffer.from(await upstream.arrayBuffer());
const totalLatencyMs = performance.now() - startedAt;
intercepted.responseHeaders = sanitizeHeaders(
Object.fromEntries(upstream.headers) as Record<string, string>
);
intercepted.responseBody = maskSecret(respBuf.toString("utf8"));
intercepted.responseSize = respBuf.length;
intercepted.status = upstream.status;
intercepted.totalLatencyMs = totalLatencyMs;
intercepted.upstreamLatencyMs = totalLatencyMs;
intercepted.proxyLatencyMs = 0;
const safeRespHeaders: Record<string, string> = {};
upstream.headers.forEach((value, key) => {
if (key.toLowerCase() === "content-length") return;
if (key.toLowerCase() === "transfer-encoding") return;
safeRespHeaders[key] = value;
});
res.writeHead(upstream.status, safeRespHeaders);
res.end(respBuf);
globalTrafficBuffer.update(intercepted.id, intercepted);
} catch (err) {
intercepted.status = "error";
intercepted.error = sanitizeErrorMessage(err);
intercepted.totalLatencyMs = performance.now() - startedAt;
globalTrafficBuffer.update(intercepted.id, intercepted);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
res.end("Bad Gateway");
} else {
res.end();
}
}
})();
}
function handleConnect(
req: http.IncomingMessage,
clientSocket: net.Socket,
head: Buffer
): void {
const target = req.url ?? "";
const [host, rawPort] = target.split(":");
const port = Number(rawPort) || 443;
const intercepted: InterceptedRequest = {
id: randomUUID(),
source: "http-proxy",
timestamp: new Date().toISOString(),
method: "CONNECT",
host,
path: `:${port}`,
requestHeaders: sanitizeHeaders(req.headers),
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: "in-flight",
note: "TLS tunnel — for body capture, redirect host via Custom Hosts mode",
};
globalTrafficBuffer.push(intercepted);
const targetSocket = net.connect(port, host);
const finalize = (status: number | "error", err?: unknown): void => {
intercepted.status = status;
if (err !== undefined) intercepted.error = sanitizeErrorMessage(err);
globalTrafficBuffer.update(intercepted.id, intercepted);
};
targetSocket.once("connect", () => {
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);
finalize(200);
});
const onError = (err: unknown): void => {
finalize("error", err);
try {
clientSocket.end();
} catch {
// socket already closed
}
try {
targetSocket.destroy();
} catch {
// already destroyed
}
};
targetSocket.once("error", onError);
clientSocket.once("error", onError);
}
/**
* Start the HTTP_PROXY listener. Resolves with a handle once `listening` has
* fired; rejects with `Error.code === "EADDRINUSE"` (and similar) when the
* bind fails so callers can surface a clean error.
*/
export function startHttpProxyServer(port: number = DEFAULT_PORT): Promise<HttpProxyServerHandle> {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.on("request", (req, res) => handleHttp(req, res));
server.on("connect", (req, socket, head) => handleConnect(req, socket as net.Socket, head));
server.once("error", (err: NodeJS.ErrnoException) => {
// Decorate with a code so callers can pattern-match without parsing strings.
reject(Object.assign(err, { code: err.code ?? "ELISTEN" }));
});
server.once("listening", () => {
const addr = server.address();
const boundPort = typeof addr === "object" && addr ? addr.port : port;
resolve({
port: boundPort,
server,
stop: () =>
new Promise<void>((res) => {
server.close(() => res());
}),
});
});
server.listen(port, "127.0.0.1");
});
}