-
Notifications
You must be signed in to change notification settings - Fork 13
/
index.js
401 lines (316 loc) · 15.2 KB
/
index.js
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
(function(window) {
"use strict";
// test for es6 support of needed functionality
try {
// template tag and Array.from support
if (!('content' in document.createElement('template') && 'from' in Array)) {
throw new Error();
}
}
catch (e) {
// missing support;
console.log('Your browser does not support the needed functionality to use the html tagged template');
return;
}
if (typeof window.html === 'undefined') {
// --------------------------------------------------
// constants
// --------------------------------------------------
const SUBSTITUTION_INDEX = 'substitutionindex:'; // tag names are always all lowercase
const SUBSTITUTION_REGEX = new RegExp(SUBSTITUTION_INDEX + '([0-9]+):', 'g');
// rejection string is used to replace xss attacks that cannot be escaped either
// because the escaped string is still executable
// (e.g. setTimeout(/* escaped string */)) or because it produces invalid results
// (e.g. <h${xss}> where xss='><script>alert(1337)</script')
// @see https://developers.google.com/closure/templates/docs/security#in_tags_and_attrs
const REJECTION_STRING = 'zXssPreventedz';
// which characters should be encoded in which contexts
const ENCODINGS = {
attribute: {
'&': '&',
'<': '<',
'>': '>'
},
uri: {
'&': '&'
}
};
// which attributes are DOM Level 0 events
// taken from https://en.wikipedia.org/wiki/DOM_events#DOM_Level_0
const DOM_EVENTS = ["onclick", "ondblclick", "onmousedown", "onmouseup", "onmouseover", "onmousemove", "onmouseout", "ondragstart", "ondrag", "ondragenter", "ondragleave", "ondragover", "ondrop", "ondragend", "onkeydown", "onkeypress", "onkeyup", "onload", "onunload", "onabort", "onerror", "onresize", "onscroll", "onselect", "onchange", "onsubmit", "onreset", "onfocus", "onblur", "onpointerdown", "onpointerup", "onpointercancel", "onpointermove", "onpointerover", "onpointerout", "onpointerenter", "onpointerleave", "ongotpointercapture", "onlostpointercapture", "oncut", "oncopy", "onpaste", "onbeforecut", "onbeforecopy", "onbeforepaste", "onafterupdate", "onbeforeupdate", "oncellchange", "ondataavailable", "ondatasetchanged", "ondatasetcomplete", "onerrorupdate", "onrowenter", "onrowexit", "onrowsdelete", "onrowinserted", "oncontextmenu", "ondrag", "ondragstart", "ondragenter", "ondragover", "ondragleave", "ondragend", "ondrop", "onselectstart", "help", "onbeforeunload", "onstop", "beforeeditfocus", "onstart", "onfinish", "onbounce", "onbeforeprint", "onafterprint", "onpropertychange", "onfilterchange", "onreadystatechange", "onlosecapture", "DOMMouseScroll", "ondragdrop", "ondragenter", "ondragexit", "ondraggesture", "ondragover", "onclose", "oncommand", "oninput", "DOMMenuItemActive", "DOMMenuItemInactive", "oncontextmenu", "onoverflow", "onoverflowchanged", "onunderflow", "onpopuphidden", "onpopuphiding", "onpopupshowing", "onpopupshown", "onbroadcast", "oncommandupdate"];
// which attributes take URIs
// taken from https://www.w3.org/TR/html4/index/attributes.html
const URI_ATTRIBUTES = ["action", "background", "cite", "classid", "codebase", "data", "href", "longdesc", "profile", "src", "usemap"];
const ENCODINGS_REGEX = {
attribute: new RegExp('[' + Object.keys(ENCODINGS.attribute).join('') + ']', 'g'),
uri: new RegExp('[' + Object.keys(ENCODINGS.uri).join('') + ']', 'g')
};
// find all attributes after the first whitespace (which would follow the tag
// name. Only used when the DOM has been clobbered to still parse attributes
const ATTRIBUTE_PARSER_REGEX = /\s([^">=\s]+)(?:="[^"]+")?/g;
// test if a javascript substitution is wrapped with quotes
const WRAPPED_WITH_QUOTES_REGEX = /^('|")[\s\S]*\1$/;
// allow custom attribute names that start or end with url or ui to do uri escaping
// @see https://developers.google.com/closure/templates/docs/security#in_urls
const CUSTOM_URI_ATTRIBUTES_REGEX = /\bur[il]|ur[il]s?$/i;
// --------------------------------------------------
// private functions
// --------------------------------------------------
/**
* Escape HTML entities in an attribute.
* @private
*
* @param {string} str - String to escape.
*
* @returns {string}
*/
function encodeAttributeHTMLEntities(str) {
return str.replace(ENCODINGS_REGEX.attribute, function(match) {
return ENCODINGS.attribute[match];
});
}
/**
* Escape entities in a URI.
* @private
*
* @param {string} str - URI to escape.
*
* @returns {string}
*/
function encodeURIEntities(str) {
return str.replace(ENCODINGS_REGEX.uri, function(match) {
return ENCODINGS.uri[match];
});
}
// --------------------------------------------------
// html tagged template function
// --------------------------------------------------
/**
* Safely convert a DOM string into DOM nodes using by using E4H and contextual
* auto-escaping techniques to prevent xss attacks.
*
* @param {string[]} strings - Safe string literals.
* @param {*} values - Unsafe substitution expressions.
*
* @returns {HTMLElement|DocumentFragment}
*/
window.html = function(strings, ...values) {
// break early if called with empty content
if (!strings[0] && values.length === 0) {
return;
}
/**
* Replace a string with substitution placeholders with its substitution values.
* @private
*
* @param {string} match - Matched substitution placeholder.
* @param {string} index - Substitution placeholder index.
*/
function replaceSubstitution(match, index) {
return values[parseInt(index, 10)];
}
// insert placeholders into the generated string so we can run it through the
// HTML parser without any malicious content.
// (this particular placeholder will even work when used to create a DOM element)
let str = strings[0];
for (let i = 0; i < values.length; i++) {
str += SUBSTITUTION_INDEX + i + ':' + strings[i+1];
}
// template tags allow any HTML (even <tr> elements out of context)
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template
let template = document.createElement('template');
template.innerHTML = str;
// find all substitution values and safely encode them using DOM APIs and
// contextual auto-escaping
let walker = document.createNodeIterator(template.content, NodeFilter.SHOW_ALL);
let node;
while (node = walker.nextNode()) {
let tag = null;
let attributesToRemove = [];
// --------------------------------------------------
// node name substitution
// --------------------------------------------------
let nodeName = node.nodeName.toLowerCase();
if (nodeName.indexOf(SUBSTITUTION_INDEX) !== -1) {
nodeName = nodeName.replace(SUBSTITUTION_REGEX, replaceSubstitution);
// createElement() should not need to be escaped to prevent XSS?
// this will throw an error if the tag name is invalid (e.g. xss tried
// to escape out of the tag using '><script>alert(1337)</script><')
// instead of replacing the tag name we'll just let the error be thrown
tag = document.createElement(nodeName);
// mark that this node needs to be cleaned up later with the newly
// created node
node._replacedWith = tag;
// use insertBefore() instead of replaceChild() so that the node Iterator
// doesn't think the new tag should be the next node
node.parentNode.insertBefore(tag, node);
}
// special case for script tags:
// using innerHTML with a string that contains a script tag causes the script
// tag to not be executed when added to the DOM. We'll need to create a script
// tag and append its contents which will make it execute correctly.
// @see http://stackoverflow.com/questions/1197575/can-scripts-be-inserted-with-innerhtml
else if (node.nodeName === 'SCRIPT') {
let script = document.createElement('script');
tag = script;
node._replacedWith = script;
node.parentNode.insertBefore(script, node);
}
// --------------------------------------------------
// attribute substitution
// --------------------------------------------------
let attributes;
if (node.attributes) {
// if the attributes property is not of type NamedNodeMap then the DOM
// has been clobbered. E.g. <form><input name="attributes"></form>.
// We'll manually build up an array of objects that mimic the Attr
// object so the loop will still work as expected.
if ( !(node.attributes instanceof NamedNodeMap) ) {
// first clone the node so we can isolate it from any children
let temp = node.cloneNode();
// parse the node string for all attributes
let attributeMatches = temp.outerHTML.match(ATTRIBUTE_PARSER_REGEX);
// get all attribute names and their value
attributes = [];
for (let i = 0; i < attributeMatches.length; i++) {
let attributeName = attributeMatches[i].trim().split('=')[0];
let attributeValue = node.getAttribute(attributeName);
attributes.push({
name: attributeName,
value: attributeValue
});
}
}
else {
// Windows 10 Firefox 44 will shift the attributes NamedNodeMap and
// push the attribute to the end when using setAttribute(). We'll have
// to clone the NamedNodeMap so the order isn't changed for setAttribute()
attributes = Array.from(node.attributes);
}
for (let i = 0; i < attributes.length; i++) {
let attribute = attributes[i];
let name = attribute.name;
let value = attribute.value;
let hasSubstitution = false;
// name has substitution
if (name.indexOf(SUBSTITUTION_INDEX) !== -1) {
name = name.replace(SUBSTITUTION_REGEX, replaceSubstitution);
// ensure substitution was with a non-empty string
if (name && typeof name === 'string') {
hasSubstitution = true;
}
// remove old attribute
attributesToRemove.push(attribute.name);
}
// value has substitution - only check if name exists (only happens
// when name is a substitution with an empty value)
if (name && value.indexOf(SUBSTITUTION_INDEX) !== -1) {
hasSubstitution = true;
// if an uri attribute has been rejected
let isRejected = false;
value = value.replace(SUBSTITUTION_REGEX, function(match, index, offset) {
if (isRejected) {
return '';
}
let substitutionValue = values[parseInt(index, 10)];
// contextual auto-escaping:
// if attribute is a DOM Level 0 event then we need to ensure it
// is quoted
if (DOM_EVENTS.indexOf(name) !== -1 &&
typeof substitutionValue === 'string' &&
!WRAPPED_WITH_QUOTES_REGEX.test(substitutionValue) ) {
substitutionValue = '"' + substitutionValue + '"';
}
// contextual auto-escaping:
// if the attribute is a uri attribute then we need to uri encode it and
// remove bad protocols
else if (URI_ATTRIBUTES.indexOf(name) !== -1 ||
CUSTOM_URI_ATTRIBUTES_REGEX.test(name)) {
// percent encode if the value is inside of a query parameter
let queryParamIndex = value.indexOf('=');
if (queryParamIndex !== -1 && offset > queryParamIndex) {
substitutionValue = encodeURIComponent(substitutionValue);
}
// entity encode if value is part of the URL
else {
substitutionValue = encodeURI( encodeURIEntities(substitutionValue) );
// only allow the : when used after http or https otherwise reject
// the entire url (will not allow any 'javascript:' or filter
// evasion techniques)
if (offset === 0 && substitutionValue.indexOf(':') !== -1) {
let protocol = substitutionValue.substring(0, 5);
if (protocol.indexOf('http') === -1) {
isRejected = true;
}
}
}
}
// contextual auto-escaping:
// HTML encode attribute value if it is not a URL or URI to prevent
// DOM Level 0 event handlers from executing xss code
else if (typeof substitutionValue === 'string') {
substitutionValue = encodeAttributeHTMLEntities(substitutionValue);
}
return substitutionValue;
});
if (isRejected) {
value = '#' + REJECTION_STRING;
}
}
// add the attribute to the new tag or replace it on the current node
// setAttribute() does not need to be escaped to prevent XSS since it does
// all of that for us
// @see https://www.mediawiki.org/wiki/DOM-based_XSS
if (tag || hasSubstitution) {
let el = (tag || node);
// optional attribute
if (name.substr(-1) === '?') {
el.removeAttribute(name);
if (value === 'true') {
name = name.slice(0, -1);
el.setAttribute(name, '');
}
}
else {
el.setAttribute(name, value);
}
}
}
}
// remove placeholder attributes outside of the attribute loop since it
// will modify the attributes NamedNodeMap indices.
// @see https://github.com/straker/html-tagged-template/issues/13
attributesToRemove.forEach(function(attribute) {
node.removeAttribute(attribute);
});
// append the current node to a replaced parent
let parentNode;
if (node.parentNode && node.parentNode._replacedWith) {
parentNode = node.parentNode;
node.parentNode._replacedWith.appendChild(node);
}
// remove the old node from the DOM
if ((node._replacedWith && node.childNodes.length === 0) ||
(parentNode && parentNode.childNodes.length === 0) ){
(parentNode || node).remove();
}
// --------------------------------------------------
// text content substitution
// --------------------------------------------------
if (node.nodeType === 3 && node.nodeValue.indexOf(SUBSTITUTION_INDEX) !== -1) {
let nodeValue = node.nodeValue.replace(SUBSTITUTION_REGEX, replaceSubstitution);
// createTextNode() should not need to be escaped to prevent XSS?
let text = document.createTextNode(nodeValue);
// since the parent node has already gone through the iterator, we can use
// replaceChild() here
node.parentNode.replaceChild(text, node);
}
}
// return the documentFragment for multiple nodes
if (template.content.childNodes.length > 1) {
return template.content;
}
return template.content.firstChild;
};
}
})(window);