-
Notifications
You must be signed in to change notification settings - Fork 62
/
xml.ts
290 lines (262 loc) · 9.24 KB
/
xml.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import * as util from "util";
import * as xmlCrypto from "xml-crypto";
import * as xmlenc from "xml-encryption";
import * as xmldom from "@xmldom/xmldom";
import * as xml2js from "xml2js";
import * as xmlbuilder from "xmlbuilder";
import {
isValidSamlSigningOptions,
NameID,
SamlSigningOptions,
XmlJsObject,
XMLOutput,
XmlSignatureLocation,
} from "./types";
import * as algorithms from "./algorithms";
import { assertRequired } from "./utility";
import { certToPEM } from "./crypto";
type SelectedValue = string | number | boolean | Node;
const selectXPath = <T extends SelectedValue>(
guard: (values: SelectedValue[]) => values is T[],
node: Node,
xpath: string
): T[] => {
const result = xmlCrypto.xpath(node, xpath);
if (!guard(result)) {
throw new Error("invalid xpath return type");
}
return result;
};
const attributesXPathTypeGuard = (values: SelectedValue[]): values is Attr[] => {
return values.every((value) => {
if (typeof value != "object") {
return false;
}
return typeof value.nodeType === "number" && value.nodeType === value.ATTRIBUTE_NODE;
});
};
const elementsXPathTypeGuard = (values: SelectedValue[]): values is Element[] => {
return values.every((value) => {
if (typeof value != "object") {
return false;
}
return typeof value.nodeType === "number" && value.nodeType === value.ELEMENT_NODE;
});
};
export const xpath = {
selectAttributes: (node: Node, xpath: string): Attr[] =>
selectXPath(attributesXPathTypeGuard, node, xpath),
selectElements: (node: Node, xpath: string): Element[] =>
selectXPath(elementsXPathTypeGuard, node, xpath),
};
export const decryptXml = async (xml: string, decryptionKey: string | Buffer) =>
util.promisify(xmlenc.decrypt).bind(xmlenc)(xml, { key: decryptionKey });
/**
* 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: string): string => {
return xml.replace(/\r\n?/g, "\n");
};
/**
* 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.
*/
export const validateSignature = (
fullXml: string,
currentNode: Element,
certs: string[]
): boolean => {
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 = 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 = 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 certs.some((certToCheck) => {
return validateXmlSignatureForCert(signature, certToPEM(certToCheck), fullXml, currentNode);
});
};
/**
* This function checks that the |signature| is signed with a given |cert|.
*/
export const validateXmlSignatureForCert = (
signature: Node,
certPem: string,
fullXml: string,
currentNode: Element
): boolean => {
const sig = new xmlCrypto.SignedXml();
sig.keyInfoProvider = {
file: "",
getKeyInfo: () => "<X509Data></X509Data>",
getKey: () => Buffer.from(certPem),
};
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.references.length != 1) return false;
const refUri = sig.references[0].uri;
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 = xpath.selectElements(
currentNode.ownerDocument,
"//*[@" + idAttribute + "='" + refId + "']"
);
if (totalReferencedNodes.length > 1) {
return false;
}
fullXml = normalizeNewlines(fullXml);
return sig.checkSignature(fullXml);
};
export const signXml = (
xml: string,
xpath: string,
location: XmlSignatureLocation,
options: SamlSigningOptions
): string => {
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 (!isValidSamlSigningOptions(options)) throw new Error("options.privateKey is required");
const transforms = options.xmlSignatureTransforms ?? defaultTransforms;
const sig = new xmlCrypto.SignedXml();
if (options.signatureAlgorithm != null) {
sig.signatureAlgorithm = algorithms.getSigningAlgorithm(options.signatureAlgorithm);
}
sig.addReference(xpath, transforms, algorithms.getDigestAlgorithm(options.digestAlgorithm));
sig.signingKey = options.privateKey;
sig.computeSignature(xml, {
location,
});
return sig.getSignedXml();
};
export const parseDomFromString = (xml: string): Promise<Document> => {
return new Promise(function (resolve, reject) {
function errHandler(msg: string) {
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);
});
};
export const parseXml2JsFromString = async (xml: string | Buffer): Promise<XmlJsObject> => {
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix],
};
const parser = new xml2js.Parser(parserConfig);
return parser.parseStringPromise(xml);
};
export const buildXml2JsObject = (rootName: string, xml: XmlJsObject): string => {
const builderOpts = {
rootName,
headless: true,
};
return new xml2js.Builder(builderOpts).buildObject(xml);
};
export const buildXmlBuilderObject = (xml: XMLOutput, pretty: boolean): string => {
const options = pretty ? { pretty: true, indent: " ", newline: "\n" } : {};
return xmlbuilder.create(xml).end(options);
};
export const promiseWithNameId = async (nameid: Node): Promise<NameID> => {
const format = xpath.selectAttributes(nameid, "@Format");
return {
value: nameid.textContent,
format: format && format[0] && format[0].nodeValue,
};
};
export const getNameIdAsync = async (
doc: Node,
decryptionPvk: string | Buffer | null
): Promise<NameID> => {
const nameIds = xpath.selectElements(
doc,
"/*[local-name()='LogoutRequest']/*[local-name()='NameID']"
);
const encryptedIds = xpath.selectElements(
doc,
"/*[local-name()='LogoutRequest']/*[local-name()='EncryptedID']"
);
if (nameIds.length + encryptedIds.length > 1) {
throw new Error("Invalid LogoutRequest");
}
if (nameIds.length === 1) {
return promiseWithNameId(nameIds[0]);
}
if (encryptedIds.length === 1) {
assertRequired(
decryptionPvk,
"No decryption key found getting name ID for encrypted SAML response"
);
const encryptedDatas = xpath.selectElements(
encryptedIds[0],
"./*[local-name()='EncryptedData']"
);
if (encryptedDatas.length !== 1) {
throw new Error("Invalid LogoutRequest");
}
const encryptedDataXml = encryptedDatas[0].toString();
const decryptedXml = await decryptXml(encryptedDataXml, decryptionPvk);
const decryptedDoc = await parseDomFromString(decryptedXml);
const decryptedIds = xpath.selectElements(decryptedDoc, "/*[local-name()='NameID']");
if (decryptedIds.length !== 1) {
throw new Error("Invalid EncryptedAssertion content");
}
return await promiseWithNameId(decryptedIds[0]);
}
throw new Error("Missing SAML NameID");
};