@node-saml/node-saml
Version:
SAML 2.0 implementation for Node.js
295 lines • 14 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getNameIdAsync = exports.promiseWithNameId = exports.buildXmlBuilderObject = exports.buildXml2JsObject = exports.parseXml2JsFromString = exports.parseDomFromString = exports.signXml = exports.validateSignature = exports.getVerifiedXml = exports.decryptXml = exports.xpath = void 0;
const util = require("util");
const xmlCrypto = require("xml-crypto");
const xmlenc = require("xml-encryption");
const xmldom = require("@xmldom/xmldom");
const xml2js = require("xml2js");
const xmlbuilder = require("xmlbuilder");
const xpath_1 = require("xpath");
const types_1 = require("./types");
const algorithms = require("./algorithms");
const utility_1 = require("./utility");
const isDomNode = require("@xmldom/is-dom-node");
const debug_1 = require("debug");
const debug = (0, debug_1.default)("node-saml");
const selectXPath = (guard, node, xpath) => {
const result = (0, xpath_1.select)(xpath, node);
if (!guard(result)) {
throw new Error("Invalid xpath return type");
}
return result;
};
const attributesXPathTypeGuard = (values) => isDomNode.isArrayOfNodes(values) && values.every(isDomNode.isAttributeNode);
const elementsXPathTypeGuard = (values) => isDomNode.isArrayOfNodes(values) && values.every(isDomNode.isElementNode);
exports.xpath = {
selectAttributes: (node, xpath) => selectXPath(attributesXPathTypeGuard, node, xpath),
selectElements: (node, xpath) => selectXPath(elementsXPathTypeGuard, node, xpath),
};
const decryptXml = async (xml, decryptionKey) => util.promisify(xmlenc.decrypt).bind(xmlenc)(xml, { key: decryptionKey });
exports.decryptXml = decryptXml;
/**
* we can use this utility before passing XML to `xml-crypto`
* we are considered the XML processor and are responsible for newline normalization
* https://github.com/node-saml/passport-saml/issues/431#issuecomment-718132752
*/
const normalizeNewlines = (xml) => {
return xml.replace(/\r\n?/g, "\n");
};
/**
* // modeled after the current validateSignature method, to maintain consistency for unit tests
* Input: fullXml, the document for SignedXML context
* Input: currentNode, this node must have a Signature
* Input: pemFiles: a list of pem encoded certificates that are trusted. User is responsible for ensuring trust
* Find's a signature for the currentNode
* Return the verified contents if verified?
* Otherwise returns null
* */
const getVerifiedXml = (fullXml, currentNode, pemFiles) => {
fullXml = normalizeNewlines(fullXml);
// find any signature
const signatures = exports.xpath.selectElements(currentNode, "./*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']");
if (signatures.length < 1) {
return null;
}
if (signatures.length > 1) {
throw new Error("Too many signatures found for this element");
}
const signature = signatures[0];
const xpathTransformQuery = ".//*[local-name(.)='Transform' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']";
const transforms = exports.xpath.selectElements(signature, xpathTransformQuery);
// Reject also XMLDSIG with more than 2 Transform
if (transforms.length > 2) {
// do not return false, throw an error so that it can be caught by tests differently
throw new Error("Invalid signature, too many transforms");
}
for (const pemFile of pemFiles) {
const sig = new xmlCrypto.SignedXml();
sig.publicCert = pemFile; // public certificate to verify
sig.loadSignature(signature);
// here are the sanity checks
// They do not affect the actual security of the program
// more so to check conformance with the SAML spec
const refs = sig.getReferences();
if (refs.length !== 1)
return null;
if (!signature.parentNode) {
return null;
}
const ref = refs[0];
// only allow enveloped signature
const refUri = ref.uri;
const refId = refUri[0] === "#" ? refUri.substring(1) : refUri;
(0, utility_1.assertRequired)(refId, "signature reference uri not found");
// prevent XPath injection
if (refId.includes("'") || refId.includes('"')) {
throw new Error("ref URI included quote character ' or \". Not a valid ID, and not allowed");
}
const totalReferencedNodes = exports.xpath.selectElements(signature.ownerDocument, `//*[@ID="${refId}"]`);
if (totalReferencedNodes.length !== 1) {
throw new Error("Invalid signature: ID cannot refer to more than one element");
}
if (totalReferencedNodes[0] !== signature.parentNode) {
throw new Error("Invalid signature: Referenced node does not refer to it's parent element");
}
// actual cryptographic verification
// after verification, the referenced XML will be in `sig.signedReferences`
// do not trust any other xml (including referencedNode)
try {
if (!sig.checkSignature(fullXml)) {
continue; // no signatures verified
}
if (sig.getSignedReferences().length !== 1) {
throw new Error("Only 1 signed references should be present in signature");
}
return sig.getSignedReferences()[0];
}
catch (_a) {
// return null; // we don't return null, since we have to verify with another key
}
}
return null;
};
exports.getVerifiedXml = getVerifiedXml;
/**
* Internally deprecated Do not only return boolean value, instead return the actual signed content. SAML Libraries must only use the referenced bytes from the signature
* This function checks that the |currentNode| in the |fullXml| document contains exactly 1 valid
* signature of the |currentNode|.
*
* See https://github.com/bergie/passport-saml/issues/19 for references to some of the attack
* vectors against SAML signature verification.
*/
const _validateSignature = (fullXml, currentNode, pemFiles) => {
const xpathSigQuery = `.//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and descendant::*[local-name(.)='Reference' and @URI='#${currentNode.getAttribute("ID")}']]`;
const signatures = exports.xpath.selectElements(currentNode, xpathSigQuery);
// This function is expecting to validate exactly one signature, so if we find more or fewer
// than that, reject.
if (signatures.length !== 1) {
return false;
}
const xpathTransformQuery = ".//*[" +
"local-name(.)='Transform' and " +
"namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and " +
"ancestor::*[local-name(.)='Reference' and @URI='#" +
currentNode.getAttribute("ID") +
"']" +
"]";
const transforms = exports.xpath.selectElements(currentNode, xpathTransformQuery);
// Reject also XMLDSIG with more than 2 Transform
if (transforms.length > 2) {
// do not return false, throw an error so that it can be caught by tests differently
throw new Error("Invalid signature, too many transforms");
}
const signature = signatures[0];
return pemFiles.some((pemFile) => {
return validateXmlSignatureWithPemFile(signature, pemFile, fullXml, currentNode);
});
};
// validateSignature is deprecated, should be using getVerifiedXml
// Existing non-sensitive callers can still use validateSignature
// but new callers should use getVerifiedXml
// this allows us to deprecate it without raising a warning
exports.validateSignature = _validateSignature;
/**
* This function checks that the |signature| is signed with a given |pemFile|.
* Internally deprecated, users should not be using this for anything new
*/
const validateXmlSignatureWithPemFile = (signature, pemFile, fullXml, currentNode) => {
const sig = new xmlCrypto.SignedXml();
sig.publicCert = pemFile;
sig.loadSignature(signature);
// We expect each signature to contain exactly one reference to the top level of the xml we
// are validating, so if we see anything else, reject.
if (sig.getReferences().length !== 1)
return false;
const t = sig.getReferences();
const refUri = t[0].uri;
(0, utility_1.assertRequired)(refUri, "signature reference uri not found");
const refId = refUri[0] === "#" ? refUri.substring(1) : refUri;
// If we can't find the reference at the top level, reject
const idAttribute = currentNode.getAttribute("ID") ? "ID" : "Id";
if (currentNode.getAttribute(idAttribute) != refId)
return false;
// If we find any extra referenced nodes, reject. (xml-crypto only verifies one digest, so
// multiple candidate references is bad news)
const totalReferencedNodes = exports.xpath.selectElements(currentNode.ownerDocument, "//*[@" + idAttribute + "='" + refId + "']");
if (totalReferencedNodes.length > 1) {
return false;
}
fullXml = normalizeNewlines(fullXml);
try {
return sig.checkSignature(fullXml);
}
catch (err) {
debug("signature check resulted in an error: %s", err);
return false;
}
};
const signXml = (xml, xpath, location, options) => {
var _a;
const defaultTransforms = [
"http://www.w3.org/2000/09/xmldsig#enveloped-signature",
"http://www.w3.org/2001/10/xml-exc-c14n#",
];
if (!xml)
throw new Error("samlMessage is required");
if (!location)
throw new Error("location is required");
if (!options)
throw new Error("options is required");
if (!(0, types_1.isValidSamlSigningOptions)(options))
throw new Error("options.privateKey is required");
const transforms = (_a = options.xmlSignatureTransforms) !== null && _a !== void 0 ? _a : defaultTransforms;
const sig = new xmlCrypto.SignedXml();
if (options.signatureAlgorithm != null) {
sig.signatureAlgorithm = algorithms.getSigningAlgorithm(options.signatureAlgorithm);
}
sig.addReference({
xpath,
transforms,
digestAlgorithm: algorithms.getDigestAlgorithm(options.digestAlgorithm),
});
sig.privateKey = options.privateKey;
sig.publicCert = options.publicCert;
sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#";
sig.computeSignature(xml, { location });
return sig.getSignedXml();
};
exports.signXml = signXml;
const parseDomFromString = (xml) => {
return new Promise(function (resolve, reject) {
function errHandler(msg) {
return reject(new Error(msg));
}
const dom = new xmldom.DOMParser({
/**
* locator is always need for error position info
*/
locator: {},
/**
* you can override the errorHandler for xml parser
* @link http://www.saxproject.org/apidoc/org/xml/sax/ErrorHandler.html
*/
errorHandler: { error: errHandler, fatalError: errHandler },
}).parseFromString(xml, "text/xml");
if (!Object.prototype.hasOwnProperty.call(dom, "documentElement")) {
return reject(new Error("Not a valid XML document"));
}
return resolve(dom);
});
};
exports.parseDomFromString = parseDomFromString;
const parseXml2JsFromString = async (xml) => {
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix],
};
const parser = new xml2js.Parser(parserConfig);
return parser.parseStringPromise(xml);
};
exports.parseXml2JsFromString = parseXml2JsFromString;
const buildXml2JsObject = (rootName, xml) => {
const builderOpts = { rootName, headless: true };
return new xml2js.Builder(builderOpts).buildObject(xml);
};
exports.buildXml2JsObject = buildXml2JsObject;
const buildXmlBuilderObject = (xml, pretty) => {
const options = pretty ? { pretty: true, indent: " ", newline: "\n" } : {};
return xmlbuilder.create(xml).end(options);
};
exports.buildXmlBuilderObject = buildXmlBuilderObject;
const promiseWithNameId = async (nameid) => {
const format = exports.xpath.selectAttributes(nameid, "@Format");
return { value: nameid.textContent, format: format && format[0] && format[0].nodeValue };
};
exports.promiseWithNameId = promiseWithNameId;
const getNameIdAsync = async (doc, decryptionPvk) => {
const nameIds = exports.xpath.selectElements(doc, "/*[local-name()='LogoutRequest']/*[local-name()='NameID']");
const encryptedIds = exports.xpath.selectElements(doc, "/*[local-name()='LogoutRequest']/*[local-name()='EncryptedID']");
if (nameIds.length + encryptedIds.length > 1) {
throw new Error("Invalid LogoutRequest: multiple ID elements");
}
if (nameIds.length === 1) {
return (0, exports.promiseWithNameId)(nameIds[0]);
}
if (encryptedIds.length === 1) {
(0, utility_1.assertRequired)(decryptionPvk, "No decryption key found getting name ID for encrypted SAML response");
const encryptedData = exports.xpath.selectElements(encryptedIds[0], "./*[local-name()='EncryptedData']");
if (encryptedData.length !== 1) {
throw new Error("Invalid LogoutRequest: no EncryptedData element found");
}
const encryptedDataXml = encryptedData[0].toString();
const decryptedXml = await (0, exports.decryptXml)(encryptedDataXml, decryptionPvk);
const decryptedDoc = await (0, exports.parseDomFromString)(decryptedXml);
const decryptedIds = exports.xpath.selectElements(decryptedDoc, "/*[local-name()='NameID']");
if (decryptedIds.length !== 1) {
throw new Error("Invalid EncryptedData content");
}
return await (0, exports.promiseWithNameId)(decryptedIds[0]);
}
throw new Error("Missing SAML NameID");
};
exports.getNameIdAsync = getNameIdAsync;
//# sourceMappingURL=xml.js.map