Type check safe-chain package

This commit is contained in:
Hans Ott 2025-11-01 13:06:06 +01:00
parent d5dc801c00
commit c88b1a624f
60 changed files with 1179 additions and 33 deletions

View file

@ -12,6 +12,10 @@ export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem");
}
/**
* @param {string} hostname
* @returns {{privateKey: string, certificate: string}}
*/
export function generateCertForHost(hostname) {
let existingCert = certCache.get(hostname);
if (existingCert) {

View file

@ -3,6 +3,11 @@ import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {(target: string) => Promise<boolean>} isAllowed
*/
export function mitmConnect(req, clientSocket, isAllowed) {
const { hostname } = new URL(`http://${req.url}`);
@ -16,6 +21,7 @@ export function mitmConnect(req, clientSocket, isAllowed) {
server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
// @ts-expect-error Property 'headersSent' does not exist on type 'Socket'
if (!clientSocket.headersSent) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
} else if (clientSocket.writable) {
@ -30,10 +36,22 @@ export function mitmConnect(req, clientSocket, isAllowed) {
server.emit("connection", clientSocket);
}
/**
* @param {string} hostname
* @param {(target: string) => Promise<boolean>} isAllowed
* @returns {import("https").Server}
*/
function createHttpsServer(hostname, isAllowed) {
const cert = generateCertForHost(hostname);
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {Promise<void>}
*/
async function handleRequest(req, res) {
// @ts-expect-error req.url might be undefined
const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`;
@ -58,6 +76,10 @@ function createHttpsServer(hostname, isAllowed) {
return server;
}
/**
* @param {string} url
* @returns {*|string}
*/
function getRequestPathAndQuery(url) {
if (url.startsWith("http://") || url.startsWith("https://")) {
const parsedUrl = new URL(url);
@ -66,6 +88,11 @@ function getRequestPathAndQuery(url) {
return url;
}
/**
* @param {import("http").IncomingMessage} req
* @param {string} hostname
* @param {import("http").ServerResponse} res
*/
function forwardRequest(req, hostname, res) {
const proxyReq = createProxyRequest(hostname, req, res);
@ -88,7 +115,15 @@ function forwardRequest(req, hostname, res) {
});
}
/**
* @param {string} hostname
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {import("http").ClientRequest}
*/
function createProxyRequest(hostname, req, res) {
/** @type {import("http").RequestOptions} */
const options = {
hostname: hostname,
port: 443,
@ -97,7 +132,9 @@ function createProxyRequest(hostname, req, res) {
headers: { ...req.headers },
};
delete options.headers.host;
if (options.headers && "host" in options.headers) {
delete options.headers["host"];
}
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) {
@ -115,6 +152,7 @@ function createProxyRequest(hostname, req, res) {
}
});
// @ts-expect-error statusCode might be undefined
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});

View file

@ -1,5 +1,9 @@
export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
/**
* @param {string} url
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
export function parsePackageFromUrl(url) {
let packageName, version, registry;

View file

@ -1,7 +1,14 @@
import * as http from "http";
import * as https from "https";
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {void}
*/
export function handleHttpProxyRequest(req, res) {
// @ts-expect-error req.url might be undefined
const url = new URL(req.url);
// The protocol for the plainHttpProxy should usually only be http:
@ -20,9 +27,11 @@ export function handleHttpProxyRequest(req, res) {
const proxyRequest = protocol
.request(
// @ts-expect-error req.url might be undefined
req.url,
{ method: req.method, headers: req.headers },
(proxyRes) => {
// @ts-expect-error statusCode might be undefined
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);

View file

@ -9,6 +9,9 @@ import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
*/
const state = {
port: null,
blockedRequests: [],
@ -24,6 +27,9 @@ export function createSafeChainProxy() {
};
}
/**
* @returns {Record<string, string>}
*/
function getSafeChainProxyEnvironmentVariables() {
if (!state.port) {
return {};
@ -36,6 +42,11 @@ function getSafeChainProxyEnvironmentVariables() {
};
}
/**
* @param {Record<string, string>} env
*
* @returns {Record<string, string>}
*/
export function mergeSafeChainProxyEnvironmentVariables(env) {
const proxyEnv = getSafeChainProxyEnvironmentVariables();
@ -67,6 +78,11 @@ function createProxyServer() {
return server;
}
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function startServer(server) {
return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port
@ -86,6 +102,11 @@ function startServer(server) {
});
}
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function stopServer(server) {
return new Promise((resolve) => {
try {
@ -99,10 +120,18 @@ function stopServer(server) {
});
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
function handleConnect(req, clientSocket, head) {
// CONNECT method is used for HTTPS requests
// It establishes a tunnel to the server identified by the request URL
// @ts-expect-error req.url might be undefined
if (knownRegistries.some((reg) => req.url.includes(reg))) {
// For npm and yarn registries, we want to intercept and inspect the traffic
// so we can block packages with malware
@ -113,6 +142,10 @@ function handleConnect(req, clientSocket, head) {
}
}
/**
* @param {string} url
* @returns {Promise<boolean>}
*/
async function isAllowedUrl(url) {
const { packageName, version } = parsePackageFromUrl(url);

View file

@ -1,6 +1,13 @@
import * as net from "net";
import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
export function tunnelRequest(req, clientSocket, head) {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
@ -21,9 +28,17 @@ export function tunnelRequest(req, clientSocket, head) {
}
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
function tunnelRequestToDestination(req, clientSocket, head) {
const { port, hostname } = new URL(`http://${req.url}`);
// @ts-expect-error port from URL is a string but net.connect accepts number
const serverSocket = net.connect(port || 443, hostname, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head);
@ -49,11 +64,18 @@ function tunnelRequestToDestination(req, clientSocket, head) {
});
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
* @param {string} proxyUrl
*/
function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
const { port, hostname } = new URL(`http://${req.url}`);
const proxy = new URL(proxyUrl);
// Connect to proxy server
// @ts-expect-error net.connect wants port as number but proxy.port is string
const proxySocket = net.connect({
host: proxy.hostname,
port: proxy.port,