From e251908cb306ae5b97f27177afa04bd6f0bbb5ec Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 6 Nov 2025 18:00:11 +0100 Subject: [PATCH] Add interceptors for MITM --- .../interceptors/interceptorBuilder.js | 50 +++++++++++++++++++ .../interceptors/requestInterceptorBuilder.js | 30 +++++++++++ .../src/registryProxy/mitmRequestHandler.js | 23 ++++++--- .../src/registryProxy/registryProxy.js | 31 ++++++++++-- 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js new file mode 100644 index 0000000..574abb9 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -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} onRequest + * @property {() => Interceptor} build + * + * @typedef {Object} Interceptor + * @property {(targetUrl: string) => Promise} handleRequest + */ + +import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js"; + +/** + * @returns {InterceptorBuilder} + */ +export function createInterceptorBuilder() { + /** + * @type {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} + */ + const requestHandlers = []; + + return { + onRequest(requestFunc) { + requestHandlers.push(requestFunc); + }, + build() { + return buildInterceptor(requestHandlers); + }, + }; +} + +/** + * @param {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} 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(); + }, + }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js new file mode 100644 index 0000000..e0d560a --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -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, + }; + }, + }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 6f7b20e..58f220c 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -3,12 +3,16 @@ import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; +/** + * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor + */ + /** * @param {import("http").IncomingMessage} req * @param {import("http").ServerResponse} clientSocket - * @param {(target: string) => Promise} 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}`); 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. }); - const server = createHttpsServer(hostname, isAllowed); + const server = createHttpsServer(hostname, interceptor); server.on("error", (err) => { ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); @@ -41,10 +45,10 @@ export function mitmConnect(req, clientSocket, isAllowed) { /** * @param {string} hostname - * @param {(target: string) => Promise} isAllowed + * @param {Interceptor} interceptor * @returns {import("https").Server} */ -function createHttpsServer(hostname, isAllowed) { +function createHttpsServer(hostname, interceptor) { const cert = generateCertForHost(hostname); /** @@ -64,10 +68,13 @@ function createHttpsServer(hostname, isAllowed) { const pathAndQuery = getRequestPathAndQuery(req.url); 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}`); - res.writeHead(403, "Forbidden - blocked by safe-chain"); - res.end("Blocked by safe-chain"); + res.writeHead(blockResponse.statusCode, blockResponse.message); + res.end(blockResponse.message); return; } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index c5e272b..d66f397 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -4,10 +4,19 @@ import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { getCaCertPath } from "./certUtils.js"; import { auditChanges } from "../scanning/audit/index.js"; -import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + knownJsRegistries, + knownPipRegistries, + parsePackageFromUrl, +} from "./parsePackageFromUrl.js"; +import { + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; +import { createInterceptorBuilder } from "./interceptors/interceptorBuilder.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -143,7 +152,7 @@ function handleConnect(req, clientSocket, head) { } if (isKnownRegistry) { - mitmConnect(req, clientSocket, isAllowedUrl); + mitmConnect(req, clientSocket, createMitmInterceptor()); } else { // For other hosts, just tunnel the request to the destination tcp socket 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 * @returns {Promise}