Add interceptors for MITM

This commit is contained in:
Sander Declerck 2025-11-06 18:00:11 +01:00
parent 0b056e92de
commit e251908cb3
No known key found for this signature in database
4 changed files with 123 additions and 11 deletions

View file

@ -0,0 +1,50 @@
/**
* @typedef {import('./requestInterceptorBuilder.js').RequestInterceptorBuilder} RequestInterceptorBuilder
* @typedef {import('./requestInterceptorBuilder.js').RequestInterceptor} RequestInterceptor
*
* @typedef {Object} InterceptorBuilder
* @property {(requestFunc: (requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>) => void} onRequest
* @property {() => Interceptor} build
*
* @typedef {Object} Interceptor
* @property {(targetUrl: string) => Promise<RequestInterceptor>} handleRequest
*/
import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js";
/**
* @returns {InterceptorBuilder}
*/
export function createInterceptorBuilder() {
/**
* @type {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>>}
*/
const requestHandlers = [];
return {
onRequest(requestFunc) {
requestHandlers.push(requestFunc);
},
build() {
return buildInterceptor(requestHandlers);
},
};
}
/**
* @param {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>>} requestHandlers
* @returns {Interceptor}
*/
function buildInterceptor(requestHandlers) {
return {
async handleRequest(targetUrl) {
const reqInterceptorBuilder = createRequestInterceptorBuilder(targetUrl);
for (const handler of requestHandlers) {
await handler(reqInterceptorBuilder);
}
return reqInterceptorBuilder.build();
},
};
}

View file

@ -0,0 +1,30 @@
/**
* @typedef {Object} RequestInterceptorBuilder
* @property {string} targetUrl
* @property {(statusCode: number, message: string) => void} blockRequest
* @property {() => RequestInterceptor} build
*
* @typedef {Object} RequestInterceptor
* @property {{statusCode: number, message: string} | undefined} blockResponse
*/
/**
* @param {string} targetUrl
* @returns {RequestInterceptorBuilder}
*/
export function createRequestInterceptorBuilder(targetUrl) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
return {
targetUrl,
blockRequest(statusCode, message) {
blockResponse = { statusCode, message };
},
build() {
return {
blockResponse,
};
},
};
}

View file

@ -3,12 +3,16 @@ import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
*/
/** /**
* @param {import("http").IncomingMessage} req * @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} clientSocket * @param {import("http").ServerResponse} clientSocket
* @param {(target: string) => Promise<boolean>} isAllowed * @param {Interceptor} interceptor
*/ */
export function mitmConnect(req, clientSocket, isAllowed) { export function mitmConnect(req, clientSocket, interceptor) {
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`); ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
const { hostname } = new URL(`http://${req.url}`); const { hostname } = new URL(`http://${req.url}`);
@ -21,7 +25,7 @@ export function mitmConnect(req, clientSocket, isAllowed) {
// Not subscribing to 'close' event will cause node to throw and crash. // Not subscribing to 'close' event will cause node to throw and crash.
}); });
const server = createHttpsServer(hostname, isAllowed); const server = createHttpsServer(hostname, interceptor);
server.on("error", (err) => { server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
@ -41,10 +45,10 @@ export function mitmConnect(req, clientSocket, isAllowed) {
/** /**
* @param {string} hostname * @param {string} hostname
* @param {(target: string) => Promise<boolean>} isAllowed * @param {Interceptor} interceptor
* @returns {import("https").Server} * @returns {import("https").Server}
*/ */
function createHttpsServer(hostname, isAllowed) { function createHttpsServer(hostname, interceptor) {
const cert = generateCertForHost(hostname); const cert = generateCertForHost(hostname);
/** /**
@ -64,10 +68,13 @@ function createHttpsServer(hostname, isAllowed) {
const pathAndQuery = getRequestPathAndQuery(req.url); const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`; const targetUrl = `https://${hostname}${pathAndQuery}`;
if (!(await isAllowed(targetUrl))) { const interceptorResult = await interceptor.handleRequest(targetUrl);
const blockResponse = interceptorResult?.blockResponse;
if (blockResponse) {
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
res.writeHead(403, "Forbidden - blocked by safe-chain"); res.writeHead(blockResponse.statusCode, blockResponse.message);
res.end("Blocked by safe-chain"); res.end(blockResponse.message);
return; return;
} }

View file

@ -4,10 +4,19 @@ import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCaCertPath } from "./certUtils.js"; import { getCaCertPath } from "./certUtils.js";
import { auditChanges } from "../scanning/audit/index.js"; import { auditChanges } from "../scanning/audit/index.js";
import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; import {
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; knownJsRegistries,
knownPipRegistries,
parsePackageFromUrl,
} from "./parsePackageFromUrl.js";
import {
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
} from "../config/settings.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import chalk from "chalk"; import chalk from "chalk";
import { createInterceptorBuilder } from "./interceptors/interceptorBuilder.js";
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/** /**
@ -143,7 +152,7 @@ function handleConnect(req, clientSocket, head) {
} }
if (isKnownRegistry) { if (isKnownRegistry) {
mitmConnect(req, clientSocket, isAllowedUrl); mitmConnect(req, clientSocket, createMitmInterceptor());
} else { } else {
// For other hosts, just tunnel the request to the destination tcp socket // For other hosts, just tunnel the request to the destination tcp socket
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
@ -151,6 +160,22 @@ function handleConnect(req, clientSocket, head) {
} }
} }
/**
*
* @returns {import("./interceptors/interceptorBuilder.js").Interceptor}
*/
function createMitmInterceptor() {
const builder = createInterceptorBuilder();
builder.onRequest(async (req) => {
if (!(await isAllowedUrl(req.targetUrl))) {
req.blockRequest(403, "Forbidden - blocked by safe-chain");
}
});
return builder.build();
}
/** /**
* @param {string} url * @param {string} url
* @returns {Promise<boolean>} * @returns {Promise<boolean>}