Move existing proxy files to builtInProxy folder

This commit is contained in:
Sander Declerck 2026-02-11 16:04:03 +01:00
parent 03ecd0dfb9
commit ca071729be
No known key found for this signature in database
31 changed files with 766 additions and 397 deletions

View file

@ -0,0 +1,186 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// @ts-ignore - certifi has no type definitions
import certifi from "certifi";
import tls from "node:tls";
import { X509Certificate } from "node:crypto";
import { getCaCertPath } from "./certUtils.js";
import { ui } from "../../environment/userInteraction.js";
/**
* Check if a PEM string contains only parsable cert blocks.
* @param {string} pem - PEM-encoded certificate string
* @returns {boolean}
*/
function isParsable(pem) {
if (!pem || typeof pem !== "string") return false;
pem = normalizeLineEndings(pem);
const begin = "-----BEGIN CERTIFICATE-----";
const end = "-----END CERTIFICATE-----";
const blocks = [];
let idx = 0;
while (idx < pem.length) {
const start = pem.indexOf(begin, idx);
if (start === -1) break;
const stop = pem.indexOf(end, start + begin.length);
if (stop === -1) break;
const blockEnd = stop + end.length;
blocks.push(pem.slice(start, blockEnd));
idx = blockEnd;
}
if (blocks.length === 0) return false;
try {
for (const b of blocks) {
// throw if invalid
new X509Certificate(b);
}
return true;
} catch {
return false;
}
}
/**
* Build a combined CA bundle.
* Automatically includes:
* - Safe Chain CA (for MITM of known registries)
* - Mozilla roots via certifi (for public HTTPS)
* - Node's built-in root certificates (fallback)
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
*
* @returns {string} Path to the combined CA bundle PEM file
*/
export function getCombinedCaBundlePath() {
const parts = [];
// 1) Safe Chain CA (for MITM'd registries)
const safeChainPath = getCaCertPath();
try {
const safeChainPem = fs.readFileSync(safeChainPath, "utf8");
if (isParsable(safeChainPem)) parts.push(safeChainPem.trim());
} catch {
// Ignore if Safe Chain CA is not available
}
// 2) certifi (Mozilla CA bundle for all public HTTPS)
try {
const certifiPem = fs.readFileSync(certifi, "utf8");
if (isParsable(certifiPem)) parts.push(certifiPem.trim());
} catch {
// Ignore if certifi bundle is not available
}
// 3) Node's built-in root certificates
try {
const nodeRoots = tls.rootCertificates;
if (Array.isArray(nodeRoots) && nodeRoots.length) {
for (const rootPem of nodeRoots) {
if (typeof rootPem !== "string") continue;
if (isParsable(rootPem)) parts.push(rootPem.trim());
}
}
} catch {
// Ignore if unavailable
}
// 4) User's NODE_EXTRA_CA_CERTS (if set)
const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
if (userCertPath) {
const userPem = readUserCertificateFile(userCertPath);
if (userPem) {
parts.push(userPem.trim());
ui.writeVerbose(
`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`,
);
} else {
ui.writeWarning(
`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`,
);
}
}
const combined = parts.filter(Boolean).join("\n");
const target = path.join(
os.tmpdir(),
`safe-chain-ca-bundle-${Date.now()}.pem`,
);
fs.writeFileSync(target, combined, { encoding: "utf8" });
return target;
}
/**
* Normalize path
* @param {string} p - Path to normalize
* @returns {string}
*/
function normalizePathF(p) {
return p.replace(/\\/g, "/");
}
/**
* Normalize line endings to LF
* @param {string} text - Text with mixed line endings
* @returns {string}
*/
function normalizeLineEndings(text) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Read and validate user certificate file
* @param {string} certPath - Path to certificate file
* @returns {string | null} Certificate PEM content or null if invalid/unreadable
*/
function readUserCertificateFile(certPath) {
try {
// 1) Basic validation
if (typeof certPath !== "string" || certPath.trim().length === 0) {
return null;
}
// 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
const normalizedPath = normalizePathF(certPath);
if (normalizedPath.includes("..")) {
return null;
}
// 3) Check if file exists and is not a directory or symlink
let stats;
try {
stats = fs.lstatSync(certPath);
} catch {
// File doesn't exist or can't be accessed
return null;
}
if (!stats.isFile()) {
// Reject directories and symlinks
return null;
}
// 4) Read file content
let content;
try {
content = fs.readFileSync(certPath, "utf8");
} catch {
return null;
}
if (!content || typeof content !== "string") {
return null;
}
// 5) Validate PEM format
if (!isParsable(content)) {
return null;
}
return content;
} catch {
// Silently fail on any errors
return null;
}
}

View file

@ -0,0 +1,379 @@
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import tls from "node:tls";
// Utility to remove the generated bundle so the module rebuilds it on demand
function removeBundleIfExists() {
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
try {
if (fs.existsSync(target)) fs.unlinkSync(target);
} catch {
// ignore
}
}
// Utility to get a valid PEM certificate for testing
function getValidCert() {
const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
return cert;
}
describe("certBundle.getCombinedCaBundlePath", () => {
beforeEach(() => {
mock.restoreAll();
removeBundleIfExists();
});
it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => {
// Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
const safeChainPath = path.join(tmpDir, "safechain-ca.pem");
const marker = "# SAFE_CHAIN_TEST_MARKER";
const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8");
// Mock the certUtils.getCaCertPath to return our temp file
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/);
assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable");
});
it("ignores invalid Safe Chain CA but still builds from other sources", async () => {
// Write an invalid file (no cert blocks)
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
const safeChainPath = path.join(tmpDir, "safechain-invalid.pem");
const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT";
fs.writeFileSync(safeChainPath, invalidMarker, "utf8");
// Mock the certUtils.getCaCertPath to return our invalid file
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
// Ensure fresh build
removeBundleIfExists();
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots");
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
});
});
describe("certBundle.getCombinedCaBundlePath with user certs", () => {
beforeEach(() => {
mock.restoreAll();
delete process.env.NODE_EXTRA_CA_CERTS;
});
it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => {
// Mock getCaCertPath to return valid cert
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks");
// Should include base bundle (Safe Chain + Mozilla/Node roots)
assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included");
});
it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
// Create Safe Chain CA
const safeChainPath = path.join(tmpDir, "safechain.pem");
const safeChainCert = getValidCert();
fs.writeFileSync(safeChainPath, safeChainCert, "utf8");
// Create user cert file
const userCertPath = path.join(tmpDir, "user-cert.pem");
const userCert = getValidCert();
fs.writeFileSync(userCertPath, userCert, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Both certs should be in the bundle
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates");
});
it("ignores non-existent user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem";
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("ignores invalid PEM user cert", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const userCertPath = path.join(tmpDir, "invalid.pem");
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should still have Safe Chain CA only
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert");
});
it("rejects user cert with path traversal attempts", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, rejected the traversal path
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert with symlink", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a target file and a symlink to it
const targetCert = path.join(tmpDir, "target.pem");
fs.writeFileSync(targetCert, getValidCert(), "utf8");
const symlinkPath = path.join(tmpDir, "symlink.pem");
try {
fs.symlinkSync(targetCert, symlinkPath);
} catch {
// Skip test if symlinks are not supported (e.g., on Windows without admin)
return;
}
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = symlinkPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA, symlinks are rejected
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("rejects user cert that is a directory", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
const certDir = path.join(tmpDir, "certs");
fs.mkdirSync(certDir);
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = certDir;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should only have Safe Chain CA
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("handles empty string user cert path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
process.env.NODE_EXTRA_CA_CERTS = " ";
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
});
it("accepts files with CRLF line endings (Windows-style)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
// Create a real file with CRLF content to test Windows line ending support
const userCertPath = path.join(tmpDir, "user-cert-crlf.pem");
const userCert = getValidCert();
const certWithCRLF = userCert.replace(/\n/g, "\r\n");
fs.writeFileSync(userCertPath, certWithCRLF, "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF");
});
it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux)
// These should gracefully fail (return Safe Chain CA only) rather than crash
const winPaths = [
"C:\\temp\\cert.pem",
"D:\\Users\\name\\certs\\ca.pem",
"\\\\server\\share\\cert.pem"
];
for (const winPath of winPaths) {
process.env.NODE_EXTRA_CA_CERTS = winPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`);
const contents = fs.readFileSync(bundlePath, "utf8");
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
}
});
it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const safeChainPath = path.join(tmpDir, "safechain.pem");
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
mock.module("./certUtils.js", {
namedExports: {
getCaCertPath: () => safeChainPath,
},
});
const { getCombinedCaBundlePath } = await import("./certBundle.js");
// Test various Windows-style traversal attempts
const traversalPaths = [
"C:\\temp\\..\\etc\\passwd",
"D:\\Users\\..\\..\\Windows\\System32",
"\\\\server\\share\\..\\admin",
"../../../etc/passwd", // Unix-style for comparison
];
// First, get baseline bundle without user certs to know expected cert count
delete process.env.NODE_EXTRA_CA_CERTS;
const baselineBundlePath = getCombinedCaBundlePath();
const baselineContents = fs.readFileSync(baselineBundlePath, "utf8");
const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
for (const badPath of traversalPaths) {
process.env.NODE_EXTRA_CA_CERTS = badPath;
const bundlePath = getCombinedCaBundlePath();
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
const contents = fs.readFileSync(bundlePath, "utf8");
// Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`);
}
});
});

View file

@ -0,0 +1,178 @@
import forge from "node-forge";
import path from "path";
import fs from "fs";
import os from "os";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa();
const certCache = new Map();
/**
* @param {forge.pki.PublicKey} publicKey
* @returns {string}
*/
function createKeyIdentifier(publicKey) {
return forge.pki.getPublicKeyFingerprint(publicKey, {
encoding: "binary",
md: forge.md.sha1.create(),
});
}
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) {
return existingCert;
}
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
const attrs = [{ name: "commonName", value: hostname }];
cert.setSubject(attrs);
cert.setIssuer(ca.certificate.subject.attributes);
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
cert.setExtensions([
{
name: "subjectAltName",
altNames: [
{
type: 2, // DNS
value: hostname,
},
],
},
{
name: "keyUsage",
digitalSignature: true,
keyEncipherment: true,
},
{
/*
Extended Key Usage (EKU) serverAuth extension
Needed for TLS server authentication. This extension indicates the certificate's
public key may be used for TLS WWW server authentication.
Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
*/
name: "extKeyUsage",
serverAuth: true,
},
{
/*
Subject Key Identifier (SKI)
Needed for Python virtualenv SSL validation and certificate chain building.
This extension provides a means of identifying certificates containing a particular public key.
Python virtualenv environments require this for proper certificate chain validation.
System Python installations may be more lenient.
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
*/
name: "subjectKeyIdentifier",
subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
},
{
/*
Authority Key Identifier (AKI)
Needed for Python virtualenv SSL validation and certificate path validation.
This extension identifies the public key corresponding to the private key used to sign
this certificate. It links this certificate to its issuing CA certificate.
Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
*/
name: "authorityKeyIdentifier",
keyIdentifier: authorityKeyIdentifier,
},
]);
cert.sign(ca.privateKey, forge.md.sha256.create());
const result = {
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
certificate: forge.pki.certificateToPem(cert),
};
certCache.set(hostname, result);
return result;
}
function loadCa() {
const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem");
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
const certPem = fs.readFileSync(certPath, "utf8");
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
const certificate = forge.pki.certificateFromPem(certPem);
// Don't return a cert that is valid for less than 1 hour
const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
if (certificate.validity.notAfter > oneHourFromNow) {
return { privateKey, certificate };
}
}
const { privateKey, certificate } = generateCa();
fs.mkdirSync(certFolder, { recursive: true });
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
return { privateKey, certificate };
}
function generateCa() {
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
cert.setSubject(attrs);
cert.setIssuer(attrs); // Self-signed: issuer === subject
const keyIdentifier = createKeyIdentifier(cert.publicKey);
cert.setExtensions([
{
name: "basicConstraints",
cA: true,
critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
},
{
name: "keyUsage",
keyCertSign: true,
digitalSignature: true,
keyEncipherment: true,
},
{
name: "subjectKeyIdentifier",
subjectKeyIdentifier: keyIdentifier,
},
{
name: "authorityKeyIdentifier",
keyIdentifier,
},
]);
cert.sign(keys.privateKey, forge.md.sha256.create());
return {
privateKey: keys.privateKey,
certificate: cert,
};
}

View file

@ -0,0 +1,150 @@
import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
import { ui } from "../../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
/** *
* @returns {import("../registryProxy.js").SafeChainProxy} */
export function createBuiltInProxyServer() {
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
*/
const state = {
port: null,
blockedRequests: [],
};
const server = http.createServer(
// This handles direct HTTP requests (non-CONNECT requests)
// This is normally http-only traffic, but we also handle
// https for clients that don't properly use CONNECT
handleHttpProxyRequest,
);
// This handles HTTPS requests via the CONNECT method
server.on("connect", handleConnect);
return {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
hasSuppressedVersions: getHasSuppressedVersions,
getServerPort: () => state.port,
getCombinedCaBundlePath,
};
/**
* @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
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
state.port = address.port;
resolve();
} else {
reject(new Error("Failed to start proxy server"));
}
});
server.on("error", (err) => {
reject(err);
});
});
}
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function stopServer(server) {
return new Promise((resolve) => {
try {
server.close(() => {
resolve();
});
} catch {
resolve();
}
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
});
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} 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
const interceptor = createInterceptorForUrl(req.url || "");
if (interceptor) {
// Subscribe to malware blocked events
interceptor.on(
"malwareBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event,
) => {
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
},
);
mitmConnect(req, clientSocket, interceptor);
} else {
// For other hosts, just tunnel the request to the destination tcp socket
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
tunnelRequest(req, clientSocket, head);
}
}
/**
*
* @param {string} packageName
* @param {string} version
* @param {string} url
*/
function onMalwareBlocked(packageName, version, url) {
state.blockedRequests.push({ packageName, version, url });
}
function verifyNoMaliciousPackages() {
if (state.blockedRequests.length === 0) {
// No malicious packages were blocked, so nothing to block
return true;
}
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${state.blockedRequests.length} malicious package downloads`,
)}:`,
);
for (const req of state.blockedRequests) {
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
}
ui.emptyLine();
ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine();
return false;
}
}

View file

@ -0,0 +1,13 @@
import { isImdsEndpoint } from "./isImdsEndpoint.js";
/**
* Returns appropriate connection timeout for a host.
* - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
* - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
*/
export function getConnectTimeout(/** @type {string} */ host) {
if (isImdsEndpoint(host)) {
return 3000;
}
return 30000;
}

View file

@ -0,0 +1,17 @@
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string} headerName
*/
export function getHeaderValueAsString(headers, headerName) {
if (!headers) {
return undefined;
}
let header = headers[headerName];
if (Array.isArray(header)) {
return header.join(", ");
}
return header;
}

View file

@ -0,0 +1,25 @@
import {
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getEcoSystem,
} from "../../../config/settings.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js";
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function createInterceptorForUrl(url) {
const ecosystem = getEcoSystem();
if (ecosystem === ECOSYSTEM_JS) {
return npmInterceptorForUrl(url);
}
if (ecosystem === ECOSYSTEM_PY) {
return pipInterceptorForUrl(url);
}
return undefined;
}

View file

@ -0,0 +1,146 @@
import { EventEmitter } from "events";
/**
* @typedef {Object} Interceptor
* @property {(targetUrl: string) => Promise<RequestInterceptionHandler>} handleRequest
* @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on
* @property {(event: string, ...args: any[]) => boolean} emit
*
*
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
* @property {() => RequestInterceptionHandler} build
*
*
* @typedef {Object} RequestInterceptionHandler
* @property {{statusCode: number, message: string} | undefined} blockResponse
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
* @property {() => boolean} modifiesResponse
* @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
*
* @typedef {Object} MalwareBlockedEvent
* @property {string} packageName
* @property {string} version
* @property {string} targetUrl
* @property {number} timestamp
*/
/**
* @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
* @returns {Interceptor}
*/
export function interceptRequests(requestInterceptionFunc) {
return buildInterceptor([requestInterceptionFunc]);
}
/**
* @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
* @returns {Interceptor}
*/
function buildInterceptor(requestHandlers) {
const eventEmitter = new EventEmitter();
return {
async handleRequest(targetUrl) {
const requestContext = createRequestContext(targetUrl, eventEmitter);
for (const handler of requestHandlers) {
await handler(requestContext);
}
return requestContext.build();
},
on(event, listener) {
eventEmitter.on(event, listener);
return this;
},
emit(event, ...args) {
return eventEmitter.emit(event, ...args);
},
};
}
/**
* @param {string} targetUrl
* @param {import('events').EventEmitter} eventEmitter
* @returns {RequestInterceptionContext}
*/
function createRequestContext(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>>} */
let reqheaderModificationFuncs = [];
/** @type {Array<(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer>} */
let modifyBodyFuncs = [];
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
*/
function blockMalwareSetup(packageName, version) {
blockResponse = {
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
// Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
/** @returns {RequestInterceptionHandler} */
function build() {
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {NodeJS.Dict<string | string[]> | undefined}
*/
function modifyRequestHeaders(headers) {
if (headers) {
for (const func of reqheaderModificationFuncs) {
func(headers);
}
}
return headers;
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {Buffer}
*/
function modifyBody(body, headers) {
let modifiedBody = body;
for (var func of modifyBodyFuncs) {
modifiedBody = func(body, headers);
}
return modifiedBody;
}
// These functions are invoked in the proxy, allowing to apply the configured modifications
return {
blockResponse,
modifyRequestHeaders: modifyRequestHeaders,
modifiesResponse: () => modifyBodyFuncs.length > 0,
modifyBody,
};
}
// These functions are used to setup the modifications
return {
targetUrl,
blockMalware: blockMalwareSetup,
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
modifyBody: (func) => modifyBodyFuncs.push(func),
build,
};
}

View file

@ -0,0 +1,213 @@
import {
getMinimumPackageAgeHours,
getNpmMinimumPackageAgeExclusions,
} from "../../../../config/settings.js";
import { ui } from "../../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
const state = {
hasSuppressedVersions: false,
};
/**
* @param {NodeJS.Dict<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
export function modifyNpmInfoRequestHeaders(headers) {
const accept = getHeaderValueAsString(headers, "accept");
if (accept?.includes("application/vnd.npm.install-v1+json")) {
// The npm registry sometimes serves a more compact format that lacks
// the time metadata we need to filter out too new packages.
// Force the registry to return the full metadata by changing the Accept header.
headers["accept"] = "application/json";
}
return headers;
}
/**
* @param {string} url
* @returns {boolean}
*/
export function isPackageInfoUrl(url) {
// Remove query string and fragment to get the actual path
const urlWithoutParams = url.split("?")[0].split("#")[0];
// Tarball downloads end with .tgz
if (urlWithoutParams.endsWith(".tgz")) return false;
// Special endpoints start with /-/ and should not be modified
// Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access
if (urlWithoutParams.includes("/-/")) return false;
// Everything else is package metadata that can be modified
return true;
}
/**
*
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns Buffer
*/
export function modifyNpmInfoResponse(body, headers) {
try {
const contentType = getHeaderValueAsString(headers, "content-type");
if (!contentType?.toLowerCase().includes("application/json")) {
return body;
}
if (body.byteLength === 0) {
return body;
}
// utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header
const bodyContent = body.toString("utf8");
const bodyJson = JSON.parse(bodyContent);
if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) {
// Just return the current body if the format is not
return body;
}
// Check if this package is excluded from minimum age filtering
const packageName = bodyJson.name;
const exclusions = getNpmMinimumPackageAgeExclusions();
if (
packageName &&
exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern),
)
) {
ui.writeVerbose(
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`,
);
return body;
}
const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
);
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
const versions = Object.entries(bodyJson.time)
.map(([version, timestamp]) => ({
version,
timestamp,
}))
.filter((x) => x.version !== "created" && x.version !== "modified");
for (const { version, timestamp } of versions) {
const timestampValue = new Date(timestamp);
if (timestampValue > cutOff) {
deleteVersionFromJson(bodyJson, version);
if (headers) {
// When modifying the response, the etag and last-modified headers
// no longer match the content so they needs to be removed before sending the response.
delete headers["etag"];
delete headers["last-modified"];
// Removing the cache-control header will prevent the package manager from caching
// the modified response.
delete headers["cache-control"];
}
}
}
if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) {
// The latest tag was removed because it contained a package younger than the treshold.
// A new latest tag needs to be calculated
bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
}
return Buffer.from(JSON.stringify(bodyJson));
} catch (/** @type {any} */ err) {
ui.writeVerbose(
`Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`,
);
return body;
}
}
/**
* @param {any} json
* @param {string} version
*/
function deleteVersionFromJson(json, version) {
state.hasSuppressedVersions = true;
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
ui.writeVerbose(
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`,
);
delete json.time[version];
delete json.versions[version];
for (const [tag, distVersion] of Object.entries(json["dist-tags"])) {
if (version == distVersion) {
delete json["dist-tags"][tag];
}
}
}
/**
* @param {Record<string, string>} tagList
* @returns {string | undefined}
*/
function calculateLatestTag(tagList) {
const entries = Object.entries(tagList).filter(
([version, _]) => version !== "created" && version !== "modified",
);
const latestFullRelease = getMostRecentTag(
Object.fromEntries(
entries.filter(([version, _]) => !version.includes("-")),
),
);
if (latestFullRelease) {
return latestFullRelease;
}
const latestPrerelease = getMostRecentTag(
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
);
return latestPrerelease;
}
/**
* @param {Record<string, string>} tagList
* @returns {string | undefined}
*/
function getMostRecentTag(tagList) {
let current, currentDate;
for (const [version, timestamp] of Object.entries(tagList)) {
if (!currentDate || currentDate < timestamp) {
current = version;
currentDate = timestamp;
}
}
return current;
}
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}
/**
* Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching.
* @param {string} packageName
* @param {string} pattern
* @returns {boolean}
*/
function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
}
return packageName === pattern;
}

View file

@ -0,0 +1,56 @@
import {
getNpmCustomRegistries,
skipMinimumPackageAge,
} from "../../../../config/settings.js";
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
isPackageInfoUrl,
modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
const knownJsRegistries = [
"registry.npmjs.org",
"registry.yarnpkg.com",
"registry.npmjs.com",
];
/**
* @param {string} url
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
export function npmInterceptorForUrl(url) {
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
(reg) => url.includes(reg),
);
if (registry) {
return buildNpmInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("../interceptorBuilder.js").Interceptor}
*/
function buildNpmInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parseNpmPackageUrl(
reqContext.targetUrl,
registry,
);
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody(modifyNpmInfoResponse);
}
});
}

View file

@ -0,0 +1,607 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeSettings = 48;
let skipMinimumPackageAgeSetting = false;
let minimumPackageAgeExclusionsSetting = [];
mock.module("../../../../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [],
getNpmMinimumPackageAgeExclusions: () =>
minimumPackageAgeExclusionsSetting,
},
});
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => {
return false;
},
},
});
mock.module("../../../../environment/userInteraction.js", {
namedExports: {
ui: {
startProcess: () => {},
writeError: () => {},
writeInformation: () => {},
writeWarning: () => {},
writeVerbose: () => {},
writeExitWithoutInstallingMaliciousPackages: () => {},
emptyLine: () => {},
},
},
});
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
for (const packageInfoUrl of [
// Basic package metadata
"https://registry.npmjs.org/lodash",
"https://registry.npmjs.org/express",
// Scoped packages
"https://registry.npmjs.org/@vercel/functions",
"https://registry.npmjs.org/@babel/core",
"https://registry.npmjs.org/@types/node",
// With query parameters
"https://registry.npmjs.org/lodash?write=true",
"https://registry.npmjs.org/@babel/core?param=value&other=test",
// With fragments
"https://registry.npmjs.org/lodash#readme",
"https://registry.npmjs.org/@babel/core#installation",
// Version-specific metadata
"https://registry.npmjs.org/lodash/4.17.21",
"https://registry.npmjs.org/lodash/latest",
"https://registry.npmjs.org/@babel/core/7.21.4",
// URL-encoded scoped packages
"https://registry.npmjs.org/@types%2Fnode",
"https://registry.npmjs.org/@babel%2Fcore",
// With trailing slashes
"https://registry.npmjs.org/lodash/",
"https://registry.npmjs.org/@babel/core/",
]) {
it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => {
const interceptor = npmInterceptorForUrl(packageInfoUrl);
const requestInterceptor =
await interceptor.handleRequest(packageInfoUrl);
assert.equal(requestInterceptor.modifiesResponse(), true);
});
}
for (const packageUrl of [
// Regular package tarballs
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"https://registry.npmjs.org/express/-/express-4.18.2.tgz",
// Scoped package tarballs
"https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
"https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
// Tarballs with query parameters (integrity checks)
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123",
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false",
// Tarballs with fragments
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123",
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash",
// Prerelease versions
"https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
"https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
]) {
it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => {
const interceptor = npmInterceptorForUrl(packageUrl);
const requestInterceptor = await interceptor.handleRequest(packageUrl);
assert.equal(requestInterceptor.modifiesResponse(), false);
});
}
for (const specialEndpoint of [
// Security advisory endpoints
"https://registry.npmjs.org/-/npm/v1/security/advisories/bulk",
"https://registry.npmjs.org/-/npm/v1/security/audits",
"https://registry.npmjs.org/-/npm/v1/security/audits/quick",
// Search endpoints
"https://registry.npmjs.org/-/v1/search?text=lodash&size=20",
"https://registry.npmjs.org/-/v1/search?text=react&from=0",
// Package access/collaboration endpoints
"https://registry.npmjs.org/-/package/lodash/access",
"https://registry.npmjs.org/-/package/@babel/core/collaborators",
"https://registry.npmjs.org/-/package/lodash/dist-tags",
"https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest",
// User/organization endpoints
"https://registry.npmjs.org/-/user/org.couchdb.user:username",
"https://registry.npmjs.org/-/org/myorg/package",
// Anonymous metrics
"https://registry.npmjs.org/-/npm/anon-metrics/v1/",
// Ping/health check
"https://registry.npmjs.org/-/ping",
]) {
it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => {
const interceptor = npmInterceptorForUrl(specialEndpoint);
const requestInterceptor =
await interceptor.handleRequest(specialEndpoint);
assert.equal(requestInterceptor.modifiesResponse(), false);
});
}
it("Should remove packages older than the treshold", async () => {
minimumPackageAgeSettings = 5;
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
// cutoff-date here
["2.0.0"]: getDate(-4),
["3.0.0"]: getDate(-3),
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
assert.equal(Object.keys(modifiedJson.time).length, 3);
assert.equal(Object.keys(modifiedJson.versions).length, 1);
assert.ok(Object.keys(modifiedJson.time).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0"));
assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0"));
assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0"));
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
});
it("Should set the package to the new latest non-preview release", async () => {
minimumPackageAgeSettings = 5;
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest
["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest
// cutoff-date here
["2.0.0"]: getDate(-4),
["3.0.0"]: getDate(-3),
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0");
});
it("Should remove dist-tags if version was removed", async () => {
minimumPackageAgeSettings = 5;
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
alpha: "2.0.0-alpha",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-4),
["1.0.0"]: getDate(-7),
// cutoff-date here
["2.0.0-alpha"]: getDate(-4),
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
console.log(modifiedJson);
assert.equal(modifiedJson["dist-tags"]["alpha"], undefined);
});
it("Should not filter packages when skipMinimumPackageAge is enabled", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = true;
const packageUrl = "https://registry.npmjs.org/lodash";
const originalBody = JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
// cutoff-date here
["2.0.0"]: getDate(-4),
["3.0.0"]: getDate(-3),
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain unchanged
assert.equal(Object.keys(modifiedJson.versions).length, 3);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
// Latest should remain unchanged
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
});
it("Should use custom minimum package age of 48 hours", async () => {
minimumPackageAgeSettings = 48;
skipMinimumPackageAgeSetting = false;
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "4.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
["4.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-24),
["1.0.0"]: getDate(-72), // 3 days old - should remain
["2.0.0"]: getDate(-50), // ~2 days old - should remain
// 48-hour cutoff here
["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed
["4.0.0"]: getDate(-24), // 1 day old - should be removed
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
// Versions older than 48 hours should remain
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
// Versions newer than 48 hours should be removed
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
assert.ok(!Object.keys(modifiedJson.versions).includes("4.0.0"));
// Latest should be recalculated to 2.0.0
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
assert.equal(Object.keys(modifiedJson.versions).length, 2);
});
it("Should use very small minimum package age of 1 hour", async () => {
minimumPackageAgeSettings = 1;
skipMinimumPackageAgeSetting = false;
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-48),
modified: getDate(0),
["1.0.0"]: getDate(-3), // 3 hours old - should remain
["2.0.0"]: getDate(-2), // 2 hours old - should remain
// 1-hour cutoff here
["3.0.0"]: getDate(0), // just published - should be removed
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
assert.equal(Object.keys(modifiedJson.versions).length, 2);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
});
it("Should not filter packages when package is in exclusion list", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["lodash"];
const packageUrl = "https://registry.npmjs.org/lodash";
const originalBody = JSON.stringify({
name: "lodash",
["dist-tags"]: {
latest: "3.0.0",
},
versions: {
["1.0.0"]: {},
["2.0.0"]: {},
["3.0.0"]: {},
},
time: {
created: getDate(-365 * 24),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
// cutoff-date here
["2.0.0"]: getDate(-4),
["3.0.0"]: getDate(-3), // Would normally be filtered
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain unchanged since lodash is excluded
assert.equal(Object.keys(modifiedJson.versions).length, 3);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
});
it("Should filter packages when package is NOT in exclusion list", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["react"]; // Different package
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: { latest: "3.0.0" },
versions: { ["1.0.0"]: {}, ["3.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
["3.0.0"]: getDate(-3),
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
// lodash should still be filtered since it's not in exclusions
assert.equal(Object.keys(modifiedJson.versions).length, 1);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
});
it("Should handle scoped packages in exclusion list", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["@babel/core"];
const packageUrl = "https://registry.npmjs.org/@babel/core";
const originalBody = JSON.stringify({
name: "@babel/core",
["dist-tags"]: { latest: "7.0.0" },
versions: { ["6.0.0"]: {}, ["7.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-1),
["6.0.0"]: getDate(-100),
["7.0.0"]: getDate(-1), // Would normally be filtered
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain for excluded scoped package
assert.equal(Object.keys(modifiedJson.versions).length, 2);
assert.ok(Object.keys(modifiedJson.versions).includes("6.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("7.0.0"));
});
it("Should handle multiple packages in exclusion list", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["react", "lodash", "@types/node"];
const packageUrl = "https://registry.npmjs.org/lodash";
const originalBody = JSON.stringify({
name: "lodash",
["dist-tags"]: { latest: "2.0.0" },
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-1),
["1.0.0"]: getDate(-100),
["2.0.0"]: getDate(-1),
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain since lodash is in the exclusion list
assert.equal(Object.keys(modifiedJson.versions).length, 2);
});
it("Should exclude packages matching wildcard pattern @scope/*", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
const packageUrl = "https://registry.npmjs.org/@aikidosec/safe-chain";
const originalBody = JSON.stringify({
name: "@aikidosec/safe-chain",
["dist-tags"]: { latest: "2.0.0" },
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-1),
["1.0.0"]: getDate(-100),
["2.0.0"]: getDate(-1), // Would normally be filtered
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain since @aikidosec/* matches @aikidosec/safe-chain
assert.equal(Object.keys(modifiedJson.versions).length, 2);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
});
it("Should NOT exclude packages that don't match wildcard pattern", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
const packageUrl = "https://registry.npmjs.org/@other/package";
const originalBody = JSON.stringify({
name: "@other/package",
["dist-tags"]: { latest: "2.0.0" },
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-1),
["1.0.0"]: getDate(-100),
["2.0.0"]: getDate(-1),
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody,
);
const modifiedJson = JSON.parse(modifiedBody);
// Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/*
assert.equal(Object.keys(modifiedJson.versions).length, 1);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
});
it("Should reset exclusions between tests", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = []; // Reset to empty
const packageUrl = "https://registry.npmjs.org/lodash";
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
JSON.stringify({
name: "lodash",
["dist-tags"]: { latest: "2.0.0" },
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
time: {
created: getDate(-365 * 24),
modified: getDate(-1),
["1.0.0"]: getDate(-100),
["2.0.0"]: getDate(-1),
},
}),
);
const modifiedJson = JSON.parse(modifiedBody);
// Version 2.0.0 should be filtered since exclusions are empty
assert.equal(Object.keys(modifiedJson.versions).length, 1);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
});
function getDate(plusHours) {
const date = new Date();
date.setHours(date.getHours() + plusHours);
return date;
}
/**
* @param {import("../interceptorBuilder.js").Interceptor} interceptor
* @param {string} body
* @returns {Promise<string>}
*/
async function runModifyNpmInfoRequest(url, body) {
const interceptor = npmInterceptorForUrl(url);
const requestHandler = await interceptor.handleRequest(url);
if (requestHandler.modifiesResponse()) {
const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), {
["content-type"]: "application/json",
});
return modifiedBuffer.toString("utf8");
}
return body;
}
});

View file

@ -0,0 +1,268 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
let lastPackage;
let malwareResponse = false;
let customRegistries = [];
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
},
});
mock.module("../../../../config/settings.js", {
namedExports: {
LOGGING_SILENT: "silent",
LOGGING_NORMAL: "normal",
LOGGING_VERBOSE: "verbose",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getLoggingLevel: () => "normal",
getEcoSystem: () => "js",
setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false,
},
});
describe("npmInterceptor", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
const parserCases = [
// Regular packages
{
url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
expected: { packageName: "lodash", version: "4.17.21" },
},
{
url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
expected: { packageName: "express", version: "4.18.2" },
},
// Packages with hyphens in name
{
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz",
expected: { packageName: "safe-chain-test", version: "1.0.0" },
},
{
url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz",
expected: { packageName: "web-vitals", version: "3.5.0" },
},
// Preview/prerelease versions
{
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz",
expected: { packageName: "safe-chain-test", version: "0.0.1-security" },
},
{
url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
expected: { packageName: "lodash", version: "5.0.0-beta.1" },
},
{
url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
expected: { packageName: "react", version: "18.3.0-canary-abc123" },
},
// Scoped packages
{
url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz",
expected: { packageName: "@babel/core", version: "7.21.4" },
},
{
url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
expected: { packageName: "@types/node", version: "20.10.5" },
},
{
url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz",
expected: { packageName: "@angular/common", version: "17.0.8" },
},
// Scoped packages with hyphens
{
url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz",
expected: { packageName: "@safe-chain/test-package", version: "2.1.0" },
},
{
url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz",
expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" },
},
// Scoped packages with preview versions
{
url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" },
},
{
url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz",
expected: {
packageName: "@safe-chain/security-test",
version: "1.0.0-security",
},
},
// Yarn registry
{
url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz",
expected: { packageName: "lodash", version: "4.17.21" },
},
{
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
expected: { packageName: "@babel/core", version: "7.21.4" },
},
// URL to get package info, not tarball
{
url: "https://registry.npmjs.org/lodash",
expected: { packageName: undefined, version: undefined },
},
// Complex version patterns
{
url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz",
expected: {
packageName: "package-with-many-hyphens",
version: "1.0.0-rc.1+build.123",
},
},
{
url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz",
expected: {
packageName: "@scope/package-name-with-hyphens",
version: "2.0.0-beta.2",
},
},
];
parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => {
const interceptor = npmInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known npm registry",
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, expected);
});
});
it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/some-package/-/some-package-1.0.0.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry",
);
});
it("should block malicious package", async () => {
const url =
"https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz";
malwareResponse = true;
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403",
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message",
);
});
});
describe("npmInterceptor with custom registries", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
it("should create interceptor for custom registry", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created for custom registry");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "lodash",
version: "4.17.21",
});
});
it("should create interceptor for custom registry with scoped packages", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url =
"https://registry.internal.net/@company/package/-/package-1.0.0.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for custom registry with scoped package",
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "@company/package",
version: "1.0.0",
});
});
it("should handle multiple custom registries", async () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz";
const interceptor1 = npmInterceptorForUrl(url1);
const interceptor2 = npmInterceptorForUrl(url2);
assert.ok(interceptor1, "Should create interceptor for first registry");
assert.ok(interceptor2, "Should create interceptor for second registry");
await interceptor1.handleRequest(url1);
assert.deepEqual(lastPackage, {
packageName: "lodash",
version: "4.17.21",
});
await interceptor2.handleRequest(url2);
assert.deepEqual(lastPackage, {
packageName: "express",
version: "4.18.2",
});
});
it("should not create interceptor for non-custom registry", () => {
// Set custom registries for this test
customRegistries = ["npm.company.com", "registry.internal.net"];
malwareResponse = false;
const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz";
const interceptor = npmInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Should not create interceptor for unknown registry",
);
});
});

View file

@ -0,0 +1,43 @@
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
export function parseNpmPackageUrl(url, registry) {
let packageName, version;
if (!registry || !url.endsWith(".tgz")) {
return { packageName, version };
}
const registryIndex = url.indexOf(registry);
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
const separatorIndex = afterRegistry.indexOf("/-/");
if (separatorIndex === -1) {
return { packageName, version };
}
packageName = afterRegistry.substring(0, separatorIndex);
const filename = afterRegistry.substring(
separatorIndex + 3,
afterRegistry.length - 4
); // Remove /-/ and .tgz
// Extract version from filename
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
if (packageName.startsWith("@")) {
const scopedPackageName = packageName.substring(
packageName.lastIndexOf("/") + 1
);
if (filename.startsWith(scopedPackageName + "-")) {
version = filename.substring(scopedPackageName.length + 1);
}
} else {
if (filename.startsWith(packageName + "-")) {
version = filename.substring(packageName.length + 1);
}
}
return { packageName, version };
}

View file

@ -0,0 +1,135 @@
import { getPipCustomRegistries } from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
const knownPipRegistries = [
"files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function pipInterceptorForUrl(url) {
const customRegistries = getPipCustomRegistries();
const registries = [...knownPipRegistries, ...customRegistries];
const registry = registries.find((reg) => url.includes(reg));
if (registry) {
return buildPipInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
function buildPipInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry,
);
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
// Per python, packages that differ only by hyphen vs underscore are considered the same.
const hyphenName = packageName?.includes("_")
? packageName.replace(/_/g, "-")
: packageName;
const isMalicious =
(await isMalwarePackage(packageName, version)) ||
(await isMalwarePackage(hyphenName, version));
if (isMalicious) {
reqContext.blockMalware(packageName, version);
}
});
}
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parsePipPackageFromUrl(url, registry) {
let packageName, version;
// Basic validation
if (!registry || typeof url !== "string") {
return { packageName, version };
}
// Quick sanity check on the URL + parse
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName, version };
}
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName, version };
}
const filename = decodeURIComponent(lastSegment);
// Parse Python package downloads from PyPI/pythonhosted.org
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
// Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
// Examples:
// foo_bar-2.0.0-py3-none-any.whl
// foo_bar-2.0.0-py3-none-any.whl.metadata
const wheelExtRe = /\.whl(?:\.metadata)?$/;
const wheelExtMatch = filename.match(wheelExtRe);
if (wheelExtMatch) {
const base = filename.replace(wheelExtRe, "");
const firstDash = base.indexOf("-");
if (firstDash > 0) {
const dist = base.slice(0, firstDash); // may contain underscores
const rest = base.slice(firstDash + 1); // version + the rest of tags
const secondDash = rest.indexOf("-");
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
packageName = dist;
version = rawVersion;
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
const sdistExtWithMetadataRe =
/\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
if (sdistExtMatch) {
const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-");
if (lastDash > 0 && lastDash < base.length - 1) {
packageName = base.slice(0, lastDash);
version = base.slice(lastDash + 1);
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Unknown file type or invalid
return { packageName: undefined, version: undefined };
}

View file

@ -0,0 +1,193 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor custom registries", async () => {
let lastPackage;
let malwareResponse = false;
let customRegistries = [];
mock.module("../../../config/settings.js", {
namedExports: {
getPipCustomRegistries: () => customRegistries,
},
});
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
it("should create interceptor for custom registry", () => {
customRegistries = ["my-custom-registry.example.com"];
const url =
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created for custom registry");
});
it("should parse package from custom registry URL", async () => {
customRegistries = ["my-custom-registry.example.com"];
const url =
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "foobar",
version: "1.2.3",
});
});
it("should parse wheel package from custom registry URL", async () => {
customRegistries = ["private-pypi.internal.com"];
const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "foo-bar",
version: "2.0.0",
});
});
it("should handle multiple custom registries", async () => {
customRegistries = ["registry-one.example.com", "registry-two.example.com"];
const url1 =
"https://registry-one.example.com/packages/package1-1.0.0.tar.gz";
const url2 =
"https://registry-two.example.com/packages/package2-2.0.0.tar.gz";
const interceptor1 = pipInterceptorForUrl(url1);
const interceptor2 = pipInterceptorForUrl(url2);
assert.ok(interceptor1, "Interceptor should be created for first registry");
assert.ok(
interceptor2,
"Interceptor should be created for second registry",
);
});
it("should block malicious package from custom registry", async () => {
customRegistries = ["my-custom-registry.example.com"];
malwareResponse = true;
const url =
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403",
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message",
);
malwareResponse = false;
});
it("should still work with known registries when custom registries are set", async () => {
customRegistries = ["my-custom-registry.example.com"];
const url =
"https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known registry even with custom registries set",
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "foobar",
version: "1.2.3",
});
});
it("should not create interceptor for unknown registry when custom registries are set", () => {
customRegistries = ["my-custom-registry.example.com"];
const url =
"https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry",
);
});
it("should handle empty custom registries array", () => {
customRegistries = [];
const url =
"https://my-custom-registry.example.com/packages/foobar-1.0.0.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined when no custom registries are configured",
);
});
it("should parse .whl.metadata from custom registry", async () => {
customRegistries = ["private-pypi.internal.com"];
const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "foo-bar",
version: "2.0.0",
});
});
it("should parse .tar.gz.metadata from custom registry", async () => {
customRegistries = ["private-pypi.internal.com"];
const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, {
packageName: "foo-bar",
version: "2.0.0",
});
});
});

View file

@ -0,0 +1,145 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
return malwareResponse;
},
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [
// Valid pip URLs
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
expected: { packageName: "foo-bar", version: "0.9.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
// Poetry preflight metadata alongside wheel (.whl.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
expected: { packageName: "foo.bar", version: "1.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0b1" },
},
{
// sdist with metadata sidecar (.tar.gz.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0rc1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.post1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.dev1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0a1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
// Invalid pip URLs
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/project/foobar/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
];
parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => {
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known npm registry",
);
await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, expected);
});
});
it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry",
);
});
it("should block malicious package", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
malwareResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403",
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message",
);
});
});

View file

@ -0,0 +1,13 @@
// Instance Metadata Service (IMDS) endpoints used by cloud providers.
// Cloud SDK tools probe these to detect environment and retrieve credentials.
// When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s)
// and suppress error logging since this is expected behavior.
const imdsEndpoints = [
"metadata.google.internal",
"metadata.goog",
"169.254.169.254", // AWS, Azure, Oracle Cloud, GCP
];
export function isImdsEndpoint(/** @type {string} */ host) {
return imdsEndpoints.includes(host);
}

View file

@ -0,0 +1,234 @@
import https from "https";
import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../../environment/userInteraction.js";
import { gunzipSync, gzipSync } from "zlib";
/**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
*/
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} clientSocket
* @param {Interceptor} interceptor
*/
export function mitmConnect(req, clientSocket, interceptor) {
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
const { hostname, port } = new URL(`http://${req.url}`);
clientSocket.on("error", (err) => {
ui.writeVerbose(
`Safe-chain: Client socket error for ${req.url}: ${err.message}`,
);
// NO-OP
// This can happen if the client TCP socket sends RST instead of FIN.
// Not subscribing to 'close' event will cause node to throw and crash.
});
const server = createHttpsServer(hostname, port, interceptor);
server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
if (!clientSocket.headersSent) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
} else if (clientSocket.writable) {
clientSocket.end();
}
});
// Establish the connection
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
// Hand off the socket to the HTTPS server
server.emit("connection", clientSocket);
}
/**
* @param {string} hostname
* @param {string} port
* @param {Interceptor} interceptor
* @returns {import("https").Server}
*/
function createHttpsServer(hostname, port, interceptor) {
const cert = generateCertForHost(hostname);
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {Promise<void>}
*/
async function handleRequest(req, res) {
if (!req.url) {
ui.writeError("Safe-chain: Request missing URL");
res.writeHead(400, "Bad Request");
res.end("Bad Request: Missing URL");
return;
}
const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`;
const requestInterceptor = await interceptor.handleRequest(targetUrl);
const blockResponse = requestInterceptor.blockResponse;
if (blockResponse) {
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
res.writeHead(blockResponse.statusCode, blockResponse.message);
res.end(blockResponse.message);
return;
}
// Collect request body
forwardRequest(req, hostname, port, res, requestInterceptor);
}
const server = https.createServer(
{
key: cert.privateKey,
cert: cert.certificate,
},
handleRequest,
);
return server;
}
/**
* @param {string} url
* @returns {string}
*/
function getRequestPathAndQuery(url) {
if (url.startsWith("http://") || url.startsWith("https://")) {
const parsedUrl = new URL(url);
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
}
return url;
}
/**
* @param {import("http").IncomingMessage} req
* @param {string} hostname
* @param {string} port
* @param {import("http").ServerResponse} res
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
*/
function forwardRequest(req, hostname, port, res, requestHandler) {
const proxyReq = createProxyRequest(hostname, port, req, res, requestHandler);
proxyReq.on("error", (err) => {
ui.writeVerbose(
`Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}`,
);
res.writeHead(502);
res.end("Bad Gateway");
});
req.on("error", (err) => {
ui.writeError(
`Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}`,
);
proxyReq.destroy();
});
req.on("data", (chunk) => {
proxyReq.write(chunk);
});
req.on("end", () => {
ui.writeVerbose(
`Safe-chain: Finished proxying request to ${req.url} for ${hostname}`,
);
proxyReq.end();
});
}
/**
* @param {string} hostname
* @param {string} port
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
*
* @returns {import("http").ClientRequest}
*/
function createProxyRequest(hostname, port, req, res, requestHandler) {
/** @type {NodeJS.Dict<string | string[]> | undefined} */
let headers = { ...req.headers };
// Remove the host header from the incoming request before forwarding.
// Node's http module sets the correct host header for the target hostname automatically.
if (headers.host) {
delete headers.host;
}
headers = requestHandler.modifyRequestHeaders(headers);
/** @type {import("http").RequestOptions} */
const options = {
hostname: hostname,
port: port || 443,
path: req.url,
method: req.method,
headers: { ...headers },
};
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) {
options.agent = new HttpsProxyAgent(httpsProxy);
}
const proxyReq = https.request(options, (proxyRes) => {
proxyRes.on("error", (err) => {
ui.writeError(
`Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}`,
);
if (!res.headersSent) {
res.writeHead(502);
res.end("Bad Gateway");
}
});
if (!proxyRes.statusCode) {
ui.writeError(
`Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}`,
);
res.writeHead(500);
res.end("Internal Server Error");
return;
}
const { statusCode, headers } = proxyRes;
if (requestHandler.modifiesResponse()) {
/** @type {Array<any>} */
let chunks = [];
proxyRes.on("data", (chunk) => chunks.push(chunk));
proxyRes.on("end", () => {
/** @type {Buffer} */
let buffer = Buffer.concat(chunks);
if (proxyRes.headers["content-encoding"] === "gzip") {
buffer = gunzipSync(buffer);
}
buffer = requestHandler.modifyBody(buffer, headers);
if (proxyRes.headers["content-encoding"] === "gzip") {
buffer = gzipSync(buffer);
}
res.writeHead(statusCode, headers);
res.end(buffer);
});
} else {
// If the response is not being modified, we can
// just pipe without the need for buffering the output
res.writeHead(statusCode, headers);
proxyRes.pipe(res);
}
});
return proxyReq;
}

View file

@ -0,0 +1,95 @@
import * as http from "http";
import * as https from "https";
import { ui } from "../../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {void}
*/
export function handleHttpProxyRequest(req, res) {
if (!req.url) {
ui.writeError("Safe-chain: Request missing URL");
res.writeHead(400, "Bad Request");
res.end("Bad Request: Missing URL");
return;
}
const url = new URL(req.url);
// The protocol for the plainHttpProxy should usually only be http:
// but when the client for some reason sends an https: request directly
// instead of using the CONNECT method, we should handle it gracefully.
let protocol;
if (url.protocol === "http:") {
protocol = http;
} else if (url.protocol === "https:") {
protocol = https;
} else {
res.writeHead(502);
res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`);
return;
}
const proxyRequest = protocol
.request(
req.url,
{ method: req.method, headers: req.headers },
(proxyRes) => {
if (!proxyRes.statusCode) {
ui.writeError("Safe-chain: Proxy response missing status code");
res.writeHead(500);
res.end("Internal Server Error");
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
proxyRes.on("error", () => {
// Proxy response stream error
// Clean up client response stream
if (res.writable) {
res.end();
}
});
proxyRes.on("close", () => {
// Clean up if the proxy response stream closes
if (res.writable) {
res.end();
}
});
},
)
.on("error", (err) => {
if (!res.headersSent) {
res.writeHead(502);
res.end(`Bad Gateway: ${err.message}`);
} else {
// Headers already sent, just destroy the response
res.destroy();
}
});
req.on("error", () => {
// Client request stream error
// Abort the proxy request
proxyRequest.destroy();
});
res.on("error", () => {
// Client response stream error (client disconnected)
// Clean up proxy streams
proxyRequest.destroy();
});
res.on("close", () => {
// Client disconnected
// Abort the proxy request to avoid unnecessary work
proxyRequest.destroy();
});
req.pipe(proxyRequest);
}

View file

@ -0,0 +1,212 @@
import * as net from "net";
import { ui } from "../../environment/userInteraction.js";
import { isImdsEndpoint } from "./isImdsEndpoint.js";
import { getConnectTimeout } from "./getConnectTimeout.js";
/** @type {string[]} */
let timedoutImdsEndpoints = [];
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
export function tunnelRequest(req, clientSocket, head) {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) {
// If an HTTPS proxy is set, tunnel the request via the proxy
// This is the system proxy, not the safe-chain proxy
// The package manager will run via the safe-chain proxy
// The safe-chain proxy will then send the request to the system proxy
// Typical flow: package manager -> safe-chain proxy -> system proxy -> destination
// There are 2 processes involved in this:
// 1. Safe-chain process: has HTTPS_PROXY set to system proxy
// 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy
tunnelRequestViaProxy(req, clientSocket, head, httpsProxy);
} else {
tunnelRequestToDestination(req, clientSocket, head);
}
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
function tunnelRequestToDestination(req, clientSocket, head) {
const { port, hostname } = new URL(`http://${req.url}`);
const isImds = isImdsEndpoint(hostname);
const targetPort = Number.parseInt(port) || 443;
if (timedoutImdsEndpoints.includes(hostname)) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
if (isImds) {
ui.writeVerbose(
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
);
} else {
ui.writeError(
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
);
}
return;
}
const connectTimeout = getConnectTimeout(hostname);
// Use JS setTimeout for true connection timeout (not idle timeout).
// socket.setTimeout() measures inactivity, not time since connection attempt.
const connectTimer = setTimeout(() => {
if (isImds) {
timedoutImdsEndpoints.push(hostname);
ui.writeVerbose(
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`,
);
} else {
ui.writeError(
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`,
);
}
serverSocket.destroy();
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
}
}, connectTimeout);
const serverSocket = net.connect(targetPort, hostname, () => {
// Clear timer to prevent false timeout errors after successful connection
clearTimeout(connectTimer);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
clientSocket.on("error", () => {
// This can happen if the client TCP socket sends RST instead of FIN.
// Not subscribing to 'error' event will cause node to throw and crash.
clearTimeout(connectTimer);
if (serverSocket.writable) {
serverSocket.end();
}
});
clientSocket.on("close", () => {
// Client closed connection - clean up server socket
clearTimeout(connectTimer);
if (serverSocket.writable) {
serverSocket.end();
}
});
serverSocket.on("error", (err) => {
clearTimeout(connectTimer);
if (isImds) {
ui.writeVerbose(
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
);
} else {
ui.writeError(
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
);
}
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
});
serverSocket.on("close", () => {
// Server closed connection - clean up client socket
clearTimeout(connectTimer);
if (clientSocket.writable) {
clientSocket.end();
}
});
}
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} 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
const proxySocket = net.connect({
host: proxy.hostname,
port: Number.parseInt(proxy.port) || 80,
});
proxySocket.on("connect", () => {
// Send CONNECT request to proxy
const connectRequest = [
`CONNECT ${hostname}:${port || 443} HTTP/1.1`,
`Host: ${hostname}:${port || 443}`,
"",
"",
].join("\r\n");
proxySocket.write(connectRequest);
});
let isConnected = false;
proxySocket.once("data", (data) => {
const response = data.toString();
// Check if CONNECT succeeded (HTTP/1.1 200)
if (response.startsWith("HTTP/1.1 200")) {
isConnected = true;
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
proxySocket.write(head);
proxySocket.pipe(clientSocket);
clientSocket.pipe(proxySocket);
} else {
ui.writeError(
`Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`,
);
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
if (proxySocket.writable) {
proxySocket.end();
}
}
});
proxySocket.on("error", (err) => {
if (!isConnected) {
ui.writeError(
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
proxy.port || 8080
} - ${err.message}`,
);
if (clientSocket.writable) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
} else {
ui.writeError(
`Safe-chain: proxy socket error after connection - ${err.message}`,
);
if (clientSocket.writable) {
clientSocket.end();
}
}
});
clientSocket.on("error", () => {
if (proxySocket.writable) {
proxySocket.end();
}
});
}