From d1ce62ec6d6c8f12dec37214039f8aacc0f4f47b Mon Sep 17 00:00:00 2001 From: glacambre Date: Thu, 17 Jan 2019 08:09:10 +0100 Subject: [PATCH 1/2] html-tagged-template.js: Accept moz-extension protocol html-tagged-template.js forbids every protocol except http:// when sanitizing urls. This prevents Tridactyl from loading its icons from moz-extension urls. This commit fixes that (ignore prettier reformatting the file, the only line I actually changed was line 314 before prettier which became line 467 after prettier). --- src/lib/html-tagged-template.js | 918 +++++++++++++++++++------------- 1 file changed, 539 insertions(+), 379 deletions(-) diff --git a/src/lib/html-tagged-template.js b/src/lib/html-tagged-template.js index 3f9bbe17..07c864b7 100644 --- a/src/lib/html-tagged-template.js +++ b/src/lib/html-tagged-template.js @@ -1,408 +1,568 @@ -(function(window) { -"use strict"; +;(function(window) { + "use strict" -// test for es6 support of needed functionality -try { - // spread operator and template strings support - (function testSpreadOpAndTemplate() { - const tag = function tag(strings, ...values) {return;}; - tag`test`; - })(); + // test for es6 support of needed functionality + try { + // spread operator and template strings support + ;(function testSpreadOpAndTemplate() { + const tag = function tag(strings, ...values) { + return + } + tag`test` + })() - - // 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. where xss='><') - // 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.
. - // 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 - }); - } + // template tag and Array.from support + if ( + !( + "content" in document.createElement("template") && + "from" in Array + ) + ) { + throw new Error() } - 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); + } 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. where xss='><') + // 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) } - // entity encode if value is part of the URL - else { - substitutionValue = encodeURI( encodeURIEntities(substitutionValue) ); + // 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 - // 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; + 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.
. + // 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 && + protocol.indexOf("moz-") == -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) + } + } } - } } - } - // 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); - } + // 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) + }) - return substitutionValue; - }); + // append the current node to a replaced parent + let parentNode + if (node.parentNode && node.parentNode._replacedWith) { + parentNode = node.parentNode + node.parentNode._replacedWith.appendChild(node) + } - if (isRejected) { - value = '#' + REJECTION_STRING; + // 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) + } } - } - // 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, ''); - } + // return the documentFragment for multiple nodes + if (template.content.childNodes.length > 1) { + return template.content } - else { - el.setAttribute(name, value); - } - } + + return template.content.firstChild } - } - - // 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); +})(window) From 3ddf350dc02ee46d5f75e7ab620e8155103fbf02 Mon Sep 17 00:00:00 2001 From: glacambre Date: Fri, 18 Jan 2019 19:20:21 +0100 Subject: [PATCH 2/2] html-tagged-template.js: Authorize more protocols https://github.com/straker/html-tagged-template/issues/26 discusses authorizing the data:// protocol. The gist of it is that it's dangerous because data:text/html can be used for XSS attacks. We circumvent this problem by only explicitly allowing a few image formats formatted as base64. --- src/lib/html-tagged-template.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/html-tagged-template.js b/src/lib/html-tagged-template.js index 07c864b7..191f77fc 100644 --- a/src/lib/html-tagged-template.js +++ b/src/lib/html-tagged-template.js @@ -457,14 +457,24 @@ substitutionValue.indexOf(":") !== -1 ) { - let protocol = substitutionValue.substring( - 0, - 5, - ) + const authorized_protocols = [ + "http://", + "https://", + "moz-extension://", + "about://", + "data:image/png;base64", + "data:image/gif;base64", + "data:image/jpg;base64", + "data:image/jpeg;base64", + "data:image/x-icon;base64", + ] + // If substitutionValue doesn't start with any of the authorized protocols if ( - protocol.indexOf("http") === - -1 && - protocol.indexOf("moz-") == -1 + !authorized_protocols.find(p => + substitutionValue.startsWith( + p, + ), + ) ) { isRejected = true }