Use CA bundle when using rama proxy

This commit is contained in:
Sander Declerck 2026-02-12 10:18:37 +01:00
parent 9a7c054a3f
commit ba604eaeaa
No known key found for this signature in database
12 changed files with 267 additions and 421 deletions

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import {
import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
import { import {
PIP_COMMAND, PIP_COMMAND,
PIP3_COMMAND, PIP3_COMMAND,
@ -118,7 +120,7 @@ export async function runPip(command, args) {
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs) // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
// so that any network request made by pip, including those outside explicit CLI args, // so that any network request made by pip, including those outside explicit CLI args,
// validates correctly under both MITM'd and tunneled HTTPS. // validates correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath(); const combinedCaPath = getProxySettings().caCertBundlePath;
// Commands that need access to persistent config/cache/state files // Commands that need access to persistent config/cache/state files
// These should not have PIP_CONFIG_FILE overridden as it would prevent them from // These should not have PIP_CONFIG_FILE overridden as it would prevent them from

View file

@ -48,11 +48,17 @@ describe("runPipCommand environment variable handling", () => {
HTTPS_PROXY: "http://localhost:8080", HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "", HTTP_PROXY: "",
}), }),
getProxySettings: () => {
return {
proxyUrl: "http://localhost:8080",
caCertBundlePath: "/tmp/test-combined-ca.pem",
};
},
}, },
}); });
// Mock certBundle to return a test combined bundle path // Mock certBundle to return a test combined bundle path
mock.module("../../registryProxy/builtInProxy/certBundle.js", { mock.module("../../registryProxy/certBundle.js", {
namedExports: { namedExports: {
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
}, },

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import {
import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/** /**
* Sets CA bundle environment variables used by Python libraries and pipx. * Sets CA bundle environment variables used by Python libraries and pipx.
@ -47,7 +49,7 @@ export async function runPipX(command, args) {
try { try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env); const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath(); const combinedCaPath = getProxySettings().caCertBundlePath;
const modifiedEnv = getPipXCaBundleEnvironmentVariables( const modifiedEnv = getPipXCaBundleEnvironmentVariables(
env, env,
combinedCaPath, combinedCaPath,

View file

@ -38,10 +38,16 @@ describe("runPipXCommand", () => {
mergeCalls.push(env); mergeCalls.push(env);
return { ...env, ...mergedEnvReturn }; return { ...env, ...mergedEnvReturn };
}, },
getProxySettings: () => {
return {
proxyUrl: "",
caCertBundlePath: "/tmp/test-combined-ca.pem",
};
},
}, },
}); });
mock.module("../../registryProxy/builtInProxy/certBundle.js", { mock.module("../../registryProxy/certBundle.js", {
namedExports: { namedExports: {
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
}, },

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import {
import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/** /**
* @returns {import("../currentPackageManager.js").PackageManager} * @returns {import("../currentPackageManager.js").PackageManager}
@ -62,7 +64,7 @@ async function runPoetryCommand(args) {
try { try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env); const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath(); const combinedCaPath = getProxySettings().caCertBundlePath;
setPoetryCaBundleEnvironmentVariables(env, combinedCaPath); setPoetryCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("poetry", args, { const result = await safeSpawn("poetry", args, {

View file

@ -1,7 +1,9 @@
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import {
import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
/** /**
* Sets CA bundle environment variables used by Python libraries and uv. * Sets CA bundle environment variables used by Python libraries and uv.
@ -53,7 +55,7 @@ export async function runUv(command, args) {
try { try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env); const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath(); const combinedCaPath = getProxySettings().caCertBundlePath;
setUvCaBundleEnvironmentVariables(env, combinedCaPath); setUvCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration

View file

@ -1,379 +0,0 @@
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

@ -2,11 +2,12 @@ import * as http from "http";
import { tunnelRequest } from "./tunnelRequestHandler.js"; import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import chalk from "chalk"; import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs";
/** * /** *
* @returns {import("../registryProxy.js").SafeChainProxy} */ * @returns {import("../registryProxy.js").SafeChainProxy} */
@ -36,7 +37,7 @@ export function createBuiltInProxyServer() {
verifyNoMaliciousPackages, verifyNoMaliciousPackages,
hasSuppressedVersions: getHasSuppressedVersions, hasSuppressedVersions: getHasSuppressedVersions,
getServerPort: () => state.port, getServerPort: () => state.port,
getCombinedCaBundlePath, getCaCert,
}; };
/** /**
@ -147,4 +148,13 @@ export function createBuiltInProxyServer() {
return false; return false;
} }
function getCaCert() {
try {
const safeChainPath = getCaCertPath();
return readFileSync(safeChainPath, "utf8");
} catch {
return null;
}
}
} }

View file

@ -5,8 +5,7 @@ import path from "node:path";
import certifi from "certifi"; import certifi from "certifi";
import tls from "node:tls"; import tls from "node:tls";
import { X509Certificate } from "node:crypto"; import { X509Certificate } from "node:crypto";
import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js";
import { ui } from "../../environment/userInteraction.js";
/** /**
* Check if a PEM string contains only parsable cert blocks. * Check if a PEM string contains only parsable cert blocks.
@ -50,20 +49,14 @@ function isParsable(pem) {
* - Mozilla roots via certifi (for public HTTPS) * - Mozilla roots via certifi (for public HTTPS)
* - Node's built-in root certificates (fallback) * - Node's built-in root certificates (fallback)
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set) * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
* @param {string | null} proxyCaCert
* *
* @returns {string} Path to the combined CA bundle PEM file * @returns {string} Path to the combined CA bundle PEM file
*/ */
export function getCombinedCaBundlePath() { export function getCombinedCaBundlePath(proxyCaCert) {
const parts = [];
// 1) Safe Chain CA (for MITM'd registries) // 1) Safe Chain CA (for MITM'd registries)
const safeChainPath = getCaCertPath(); const parts = [];
try { if (proxyCaCert && isParsable(proxyCaCert)) parts.push(proxyCaCert.trim());
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) // 2) certifi (Mozilla CA bundle for all public HTTPS)
try { try {

View file

@ -0,0 +1,180 @@
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 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",
);
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(`${marker}\n${rootPem}`);
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");
// Ensure fresh build
removeBundleIfExists();
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(invalidMarker);
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 () => {
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(getValidCert());
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 safeChainCert = getValidCert();
// 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;
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(safeChainCert);
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 invalid PEM user cert", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
const userCertPath = path.join(tmpDir, "invalid.pem");
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(getValidCert());
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("accepts files with CRLF line endings (Windows-style)", async () => {
// Create a real file with CRLF content to test Windows line ending support
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
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;
const { getCombinedCaBundlePath } = await import("./certBundle.js");
const bundlePath = getCombinedCaBundlePath(getValidCert());
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",
);
});
});

View file

@ -1,6 +1,6 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdtempSync, readFile, writeFile } from "node:fs"; import { mkdtempSync, readFile } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
@ -8,14 +8,13 @@ import { ui } from "../../environment/userInteraction.js";
import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js"; import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js";
const readFilePromise = promisify(readFile); const readFilePromise = promisify(readFile);
const writeFilePromise = promisify(writeFile);
/** /**
* @typedef {Object} RamaProxyInstance * @typedef {Object} RamaProxyInstance
* @property {import("node:child_process").ChildProcess} process * @property {import("node:child_process").ChildProcess} process
* @property {string} proxyAddress * @property {string} proxyAddress
* @property {string} metaAddress * @property {string} metaAddress
* @property {string} certPath * @property {string} caCert
*/ */
/** /**
@ -61,7 +60,7 @@ export function createRamaProxy(ramaPath) {
const url = new URL(`http://${ramaInstance.proxyAddress}`); const url = new URL(`http://${ramaInstance.proxyAddress}`);
return url.port ? parseInt(url.port, 10) : null; return url.port ? parseInt(url.port, 10) : null;
}, },
getCombinedCaBundlePath: () => ramaInstance?.certPath ?? "", getCaCert: () => ramaInstance?.caCert ?? null,
}; };
} }
@ -102,14 +101,12 @@ async function startRama(ramaPath, dataFolder) {
); );
const certResponse = await fetch(`http://${metaAddress}/ca`); const certResponse = await fetch(`http://${metaAddress}/ca`);
const cert = await certResponse.text(); const caCert = await certResponse.text();
const certPath = join(dataFolder, "cert.ca");
await writeFilePromise(certPath, cert);
return { return {
process, process,
proxyAddress, proxyAddress,
metaAddress, metaAddress,
certPath, caCert,
}; };
} }

View file

@ -1,6 +1,7 @@
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js"; import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js";
import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js"; import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js";
import { getCombinedCaBundlePath } from "./certBundle.js";
/** /**
* @typedef {Object} SafeChainProxy * @typedef {Object} SafeChainProxy
@ -9,7 +10,11 @@ import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServe
* @prop {() => boolean} verifyNoMaliciousPackages * @prop {() => boolean} verifyNoMaliciousPackages
* @prop {() => boolean} hasSuppressedVersions * @prop {() => boolean} hasSuppressedVersions
* @prop {() => Number | null} getServerPort * @prop {() => Number | null} getServerPort
* @prop {() => string} getCombinedCaBundlePath * @prop {() => string | null} getCaCert
*
* @typedef {Object} ProxySettings
* @prop {string | null} proxyUrl
* @prop {string} caCertBundlePath
*/ */
/** @type {SafeChainProxy} */ /** @type {SafeChainProxy} */
@ -31,6 +36,27 @@ export function createSafeChainProxy() {
return server; return server;
} }
/**
* @returns {ProxySettings}
*/
export function getProxySettings() {
if (!server || !server.getServerPort()) {
return {
proxyUrl: null,
caCertBundlePath: getCombinedCaBundlePath(null),
};
}
const proxyUrl = `http://localhost:${server.getServerPort()}`;
const caCert = server.getCaCert();
const caCertBundlePath = getCombinedCaBundlePath(caCert);
return {
proxyUrl,
caCertBundlePath,
};
}
/** /**
* @returns {Record<string, string>} * @returns {Record<string, string>}
*/ */
@ -39,13 +65,12 @@ function getSafeChainProxyEnvironmentVariables() {
return {}; return {};
} }
const proxyUrl = `http://localhost:${server.getServerPort()}`; const proxySettings = getProxySettings();
const caCertPath = server.getCombinedCaBundlePath();
return { return {
HTTPS_PROXY: proxyUrl, HTTPS_PROXY: proxySettings.proxyUrl ?? "",
GLOBAL_AGENT_HTTP_PROXY: proxyUrl, GLOBAL_AGENT_HTTP_PROXY: proxySettings.proxyUrl ?? "",
NODE_EXTRA_CA_CERTS: caCertPath, NODE_EXTRA_CA_CERTS: proxySettings.caCertBundlePath,
}; };
} }