UNPKG

@node-saml/node-saml

Version:

SAML 2.0 implementation for Node.js

295 lines 14 kB
"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