mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
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)`);
|
|
}
|
|
});
|
|
});
|