@sap/xssec
Version:
XS Advanced Container Security API for node.js
196 lines (162 loc) • 5.8 kB
JavaScript
const { X509Certificate } = require("crypto");
const InvalidClientCertificateError = require("../error/validation/InvalidClientCertificateError");
const { PEM_HEADER, PEM_FOOTER } = require("./constants");
module.exports = {
parsePemCertificate(pem) {
// restores escaped new lines
pem = pem.replaceAll("\\n", "\n");
// restores PEM header and footer if missing
if (!pem.startsWith(PEM_HEADER)) pem = `${PEM_HEADER}\n${pem}`;
if (!pem.endsWith(PEM_FOOTER)) pem = `${pem}\n${PEM_FOOTER}`;
try {
return new X509Certificate(pem);
} catch (error) {
throw new InvalidClientCertificateError(pem, error);
}
},
/**
* Extracts PEM certificate from either CF PEM format header or Envoy XFCC format header
* @param {string} headerValue - The x-forwarded-client-cert header value
* @returns {string|null} The PEM certificate or null if not found/invalid
*/
extractPemFromClientCertHeader(headerValue) {
if (!headerValue) {
return null;
}
if (isXfccFormat(headerValue)) {
return parseXfccToPem(headerValue);
} else {
// Assume it's already in PEM format
return headerValue;
}
},
isXfccFormat,
parseXfccToPem,
splitXfccString,
isEscaped,
extractCertFromXfccEntry,
unquoteAndDecodeCert
};
/**
* Determines if the given header value is in XFCC format.
* According to Envoy documentation, the Hash property is always set in XFCC format,
* making it the most reliable indicator.
* @param {string} headerValue - The header value to check
* @returns {boolean} true if XFCC format, false if PEM format or other
*/
function isXfccFormat(headerValue) {
if (!headerValue || typeof headerValue !== 'string') {
return false;
}
const hashPattern = /\bHash=/i;
return hashPattern.test(headerValue);
}
/**
* Parses XFCC (x-forwarded-client-cert) format and extracts the PEM certificate
* @param {string} xfccHeader - The XFCC header value
* @returns {string|null} The extracted PEM certificate or null if not found
*/
function parseXfccToPem(xfccHeader) {
if (!xfccHeader || typeof xfccHeader !== 'string') {
return null;
}
// Split by comma to handle multiple certificate entries
const xfccEntries = splitXfccString(xfccHeader, ',');
// Use the last entry in case of multiple certificates
const lastEntry = xfccEntries[xfccEntries.length - 1];
return extractCertFromXfccEntry(lastEntry);
}
/**
* Splits XFCC string by the specified delimiter while respecting quoted values
* @param {string} str - The string to split
* @param {string} delimiter - The delimiter character to split on
* @returns {string[]} Array of split parts
*/
function splitXfccString(str, delimiter) {
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < str.length; i++) {
const char = str[i];
// Handle escaped double quotes properly - only unescaped double quotes should toggle quote state
if (char === '"' && !isEscaped(str, i)) {
inQuotes = !inQuotes;
}
if (char === delimiter && !inQuotes) {
parts.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current.trim()) {
parts.push(current.trim());
}
return parts;
}
/**
* Checks if a character at the given position is escaped by counting preceding backslashes
* @param {string} str - The string to check
* @param {number} pos - The position of the character to check
* @returns {boolean} true if the character is escaped
*/
function isEscaped(str, pos) {
let backslashCount = 0;
let i = pos - 1;
// Count consecutive backslashes before the character
while (i >= 0 && str[i] === '\\') {
backslashCount++;
i--;
}
// Character is escaped if there's an odd number of backslashes before it
return backslashCount % 2 === 1;
}
/**
* Extracts the certificate from a single XFCC entry
* @param {string} xfccEntry - A single XFCC entry
* @returns {string|null} The extracted PEM certificate or null if not found
*/
function extractCertFromXfccEntry(xfccEntry) {
if (!xfccEntry) {
return null;
}
// Split the entry by semicolon to get field=value pairs
const fields = splitXfccString(xfccEntry, ';');
for (const field of fields) {
const equalIndex = field.indexOf('=');
if (equalIndex === -1) continue;
const fieldName = field.substring(0, equalIndex).trim();
const fieldValue = field.substring(equalIndex + 1).trim();
if (fieldName.toLowerCase() === 'cert') {
return unquoteAndDecodeCert(fieldValue);
}
}
return null;
}
/**
* Unquotes and URL decodes the certificate value
* @param {string} certValue - The quoted and encoded certificate value
* @returns {string|null} The decoded PEM certificate or null if invalid
*/
function unquoteAndDecodeCert(certValue) {
if (!certValue) {
return null;
}
let cert = certValue.trim();
// Remove surrounding quotes if present
if ((cert.startsWith('"') && cert.endsWith('"')) ||
(cert.startsWith("'") && cert.endsWith("'"))) {
cert = cert.slice(1, -1);
}
// Unescape escaped double quotes according to Envoy spec: \" -> "
cert = cert.replace(/\\"/g, '"');
try {
// URL decode the certificate
cert = decodeURIComponent(cert);
return cert;
} catch (error) {
// If decoding fails, return the original value without URL decoding
// but still with unescaped quotes
return cert;
}
}