mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Use CA bundle when using rama proxy
This commit is contained in:
parent
9a7c054a3f
commit
ba604eaeaa
12 changed files with 267 additions and 421 deletions
|
|
@ -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)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
180
packages/safe-chain/src/registryProxy/certBundle.spec.js
Normal file
180
packages/safe-chain/src/registryProxy/certBundle.spec.js
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>}
|
||||
*/
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue