AikidoSec-safe-chain/packages/safe-chain/src/registryProxy/certBundle.js
2025-12-08 15:36:37 -08:00

181 lines
4.8 KiB
JavaScript

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