diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 425eca4..226e814 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; import { PIP_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) // so that any network request made by pip, including those outside explicit CLI args, // 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 // These should not have PIP_CONFIG_FILE overridden as it would prevent them from diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js index b256725..24d5ca6 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js @@ -48,11 +48,17 @@ describe("runPipCommand environment variable handling", () => { HTTPS_PROXY: "http://localhost:8080", HTTP_PROXY: "", }), + getProxySettings: () => { + return { + proxyUrl: "http://localhost:8080", + caCertBundlePath: "/tmp/test-combined-ca.pem", + }; + }, }, }); // Mock certBundle to return a test combined bundle path - mock.module("../../registryProxy/builtInProxy/certBundle.js", { + mock.module("../../registryProxy/certBundle.js", { namedExports: { getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", }, diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 73a6384..e1554cb 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; /** * Sets CA bundle environment variables used by Python libraries and pipx. @@ -47,7 +49,7 @@ export async function runPipX(command, args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; const modifiedEnv = getPipXCaBundleEnvironmentVariables( env, combinedCaPath, diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js index 4b92bd1..56d75f9 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js @@ -38,10 +38,16 @@ describe("runPipXCommand", () => { mergeCalls.push(env); return { ...env, ...mergedEnvReturn }; }, + getProxySettings: () => { + return { + proxyUrl: "", + caCertBundlePath: "/tmp/test-combined-ca.pem", + }; + }, }, }); - mock.module("../../registryProxy/builtInProxy/certBundle.js", { + mock.module("../../registryProxy/certBundle.js", { namedExports: { getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", }, diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js index 72741d3..956fb05 100644 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -62,7 +64,7 @@ async function runPoetryCommand(args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; setPoetryCaBundleEnvironmentVariables(env, combinedCaPath); const result = await safeSpawn("poetry", args, { diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js index 5d6bd4f..e44922f 100644 --- a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/builtInProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; /** * Sets CA bundle environment variables used by Python libraries and uv. @@ -53,7 +55,7 @@ export async function runUv(command, args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; setUvCaBundleEnvironmentVariables(env, combinedCaPath); // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.spec.js deleted file mode 100644 index e3b58fb..0000000 --- a/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.spec.js +++ /dev/null @@ -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)`); - } - }); -}); diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js index f6b5d62..22cd394 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -2,11 +2,12 @@ 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"; +import { getCaCertPath } from "./certUtils.js"; +import { readFileSync } from "fs"; /** * * @returns {import("../registryProxy.js").SafeChainProxy} */ @@ -36,7 +37,7 @@ export function createBuiltInProxyServer() { verifyNoMaliciousPackages, hasSuppressedVersions: getHasSuppressedVersions, getServerPort: () => state.port, - getCombinedCaBundlePath, + getCaCert, }; /** @@ -147,4 +148,13 @@ export function createBuiltInProxyServer() { return false; } + + function getCaCert() { + try { + const safeChainPath = getCaCertPath(); + return readFileSync(safeChainPath, "utf8"); + } catch { + return null; + } + } } diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js similarity index 91% rename from packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js rename to packages/safe-chain/src/registryProxy/certBundle.js index 06aaf23..b42042a 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -5,8 +5,7 @@ import path from "node:path"; 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"; +import { ui } from "../environment/userInteraction.js"; /** * Check if a PEM string contains only parsable cert blocks. @@ -50,20 +49,14 @@ function isParsable(pem) { * - 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) + * @param {string | null} proxyCaCert * * @returns {string} Path to the combined CA bundle PEM file */ -export function getCombinedCaBundlePath() { - const parts = []; - +export function getCombinedCaBundlePath(proxyCaCert) { // 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 - } + const parts = []; + if (proxyCaCert && isParsable(proxyCaCert)) parts.push(proxyCaCert.trim()); // 2) certifi (Mozilla CA bundle for all public HTTPS) try { diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/certBundle.spec.js new file mode 100644 index 0000000..3287554 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certBundle.spec.js @@ -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", + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index 42b6549..3c7b50a 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtempSync, readFile, writeFile } from "node:fs"; +import { mkdtempSync, readFile } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; @@ -8,14 +8,13 @@ import { ui } from "../../environment/userInteraction.js"; import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js"; const readFilePromise = promisify(readFile); -const writeFilePromise = promisify(writeFile); /** * @typedef {Object} RamaProxyInstance * @property {import("node:child_process").ChildProcess} process * @property {string} proxyAddress * @property {string} metaAddress - * @property {string} certPath + * @property {string} caCert */ /** @@ -61,7 +60,7 @@ export function createRamaProxy(ramaPath) { const url = new URL(`http://${ramaInstance.proxyAddress}`); 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 cert = await certResponse.text(); - const certPath = join(dataFolder, "cert.ca"); - await writeFilePromise(certPath, cert); + const caCert = await certResponse.text(); return { process, proxyAddress, metaAddress, - certPath, + caCert, }; } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index fb08398..4b7ef82 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -1,6 +1,7 @@ import { ui } from "../environment/userInteraction.js"; import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js"; import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js"; +import { getCombinedCaBundlePath } from "./certBundle.js"; /** * @typedef {Object} SafeChainProxy @@ -9,7 +10,11 @@ import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServe * @prop {() => boolean} verifyNoMaliciousPackages * @prop {() => boolean} hasSuppressedVersions * @prop {() => Number | null} getServerPort - * @prop {() => string} getCombinedCaBundlePath + * @prop {() => string | null} getCaCert + * + * @typedef {Object} ProxySettings + * @prop {string | null} proxyUrl + * @prop {string} caCertBundlePath */ /** @type {SafeChainProxy} */ @@ -31,6 +36,27 @@ export function createSafeChainProxy() { 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} */ @@ -39,13 +65,12 @@ function getSafeChainProxyEnvironmentVariables() { return {}; } - const proxyUrl = `http://localhost:${server.getServerPort()}`; - const caCertPath = server.getCombinedCaBundlePath(); + const proxySettings = getProxySettings(); return { - HTTPS_PROXY: proxyUrl, - GLOBAL_AGENT_HTTP_PROXY: proxyUrl, - NODE_EXTRA_CA_CERTS: caCertPath, + HTTPS_PROXY: proxySettings.proxyUrl ?? "", + GLOBAL_AGENT_HTTP_PROXY: proxySettings.proxyUrl ?? "", + NODE_EXTRA_CA_CERTS: proxySettings.caCertBundlePath, }; }