├── .gitignore ├── .jshintrc ├── .travis.yml ├── README.md ├── bower.json ├── gulpfile.js ├── index.html ├── index.js ├── karma.conf-ci.js ├── karma.conf.js ├── package.json ├── page ├── index.html └── xss.js ├── references.md ├── securitySpec.md └── test ├── htmlPaser.test.js ├── substitution.test.js └── xss.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | coverage 4 | sauce_connect.log 5 | .env -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise" : false, // Prohibit bitwise operators (&, |, ^, etc.). 3 | "curly" : true, // Require {} for every new block or scope. 4 | "eqeqeq" : true, // Require triple equals i.e. `===`. 5 | "forin" : true, // Tolerate `for in` loops without `hasOwnPrototype`. 6 | "immed" : true, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );` 7 | "latedef" : false, // Prohibit variable use before definition. 8 | "newcap" : true, // Require capitalization of all constructor functions e.g. `new F()`. 9 | "noarg" : true, // Prohibit use of `arguments.caller` and `arguments.callee`. 10 | "noempty" : true, // Prohibit use of empty blocks. 11 | "nonew" : true, // Prohibit use of constructors for side-effects. 12 | "plusplus" : false, // Prohibit use of `++` & `--`. 13 | "regexp" : true, // Prohibit `.` and `[^...]` in regular expressions. 14 | "undef" : false, // Require all non-global variables be declared before they are used. 15 | "strict" : true, // Require `use strict` pragma in every file. 16 | "trailing" : true, // Prohibit trailing whitespaces. 17 | "browser" : true, // Standard browser globals e.g. `window`, `document`. 18 | "boss" : true , // Suppress warnings about assignments in comparisons 19 | "esversion" : 6 // Use ECMAScript 6 specific syntax 20 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.0" 4 | addons: 5 | firefox: "42.0" 6 | before_script: 7 | - "export DISPLAY=:99.0" 8 | - "sh -e /etc/init.d/xvfb start" 9 | - sleep 3 # give xvfb some time to start 10 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://travis-ci.org/straker/html-tagged-template) 2 | [](https://coveralls.io/github/straker/html-tagged-template?branch=master) 3 | 4 | # Proposal 5 | 6 | Improve the DOM creation API so developers have a cleaner, simpler interface to DOM creation and manipulation. 7 | 8 | ## Installing 9 | 10 | `npm install html-tagged-template` 11 | 12 | or with Bower 13 | 14 | `bower install html-tagged-template` 15 | 16 | ## Usage 17 | 18 | ```js 19 | let min = 0, max = 99, disabled = true; 20 | 21 | // returns an tag with all attributes set 22 | // the use of ?= denotes an optional attribute which will only be added if the 23 | // value is true 24 | let el = html``; 25 | document.body.appendChild(el); 26 | 27 | // returns a DocumentFragment with two
`UNTRUSTED DATA`
| HTML Entity Encoding |
8 | | String | Safe HTML Attributes | ``
| `clickme`
| URL Encoding |
10 | | String | Untrusted URL in a SRC or HREF attribute | `clickme`
`` |
`Selection` |
``
`` |
`UNTRUSTED HTML`
| HTML Validation (JSoup, AntiSamy, HTML Sanitizer) |
14 | | String | DOM XSS | ``;
98 | ```
99 |
100 | - Except for alphanumeric characters, escape all characters less than 256 with the \xHH format to prevent switching out of the data value into the script context or into another attribute. DO NOT use any escaping shortcuts like \" because the quote character may be matched by the HTML attribute parser which runs first. These escaping shortcuts are also susceptible to "escape-the-escape" attacks where the attacker sends \" and the vulnerable code turns that into \\" which enables the quote.
101 |
102 | ### References
103 |
104 | 1. https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.233_-_JavaScript_Escape_Before_Inserting_Untrusted_Data_into_JavaScript_Data_Values
105 |
106 | ## As CSS Content
107 |
108 | ```js
109 | html``;
110 | ```
111 |
112 | - Please note there are some CSS contexts that can never safely use untrusted data as input - EVEN IF PROPERLY CSS ESCAPED! You will have to ensure that URLs only start with "http" not "javascript" and that properties never start with "expression".
113 | - Except for alphanumeric characters, escape all characters with ASCII values less than 256 with the \HH escaping format. DO NOT use any escaping shortcuts like \" because the quote character may be matched by the HTML attribute parser which runs first. These escaping shortcuts are also susceptible to "escape-the-escape" attacks where the attacker sends \" and the vulnerable code turns that into \\" which enables the quote.
114 | - If attribute is quoted, breaking out requires the corresponding quote. All attributes should be quoted but your encoding should be strong enough to prevent XSS when untrusted data is placed in unquoted contexts. Unquoted attributes can be broken out of with many characters including [space] % * + , - / ; < = > ^ and |. Also, the tag will close the style block even though it is inside a quoted string because the HTML parser runs before the JavaScript parser. Please note that we recommend aggressive CSS encoding and validation to prevent XSS attacks for both quoted and unquoted attributes.
115 |
116 | ### References
117 |
118 | 1. https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.234_-_CSS_Escape_And_Strictly_Validate_Before_Inserting_Untrusted_Data_into_HTML_Style_Property_Values
119 |
120 | ## Mix
121 |
122 | ```js
123 | html`${text} `;
124 | ```
125 |
126 | - ${attr}=${value} ${var} is difficult – was ${var} suppose to be part of ${attr} or it's own attribute?
127 | - unless the attribute was quoted, any whitespace will be treated as a new attribute, so in the example above ${var} would be a new attribute
128 |
129 | # References
130 |
131 | 1. https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet
132 |
133 | # Idea on how to combine E4H principles and contextual auto escaping
134 |
135 | 1. replace all substitutions with placeholder values (their index in the array)
136 | 2. create DOM as normal using HTML parser (no chance for XSS since there is no user generated expression)
137 | - will need to check for correct closing tags/attributes or something so the HTML parser doesn't freak out
138 | 3. with the DOM created, we can use setAttribute() for safely encoding attribute values (prevent attribution injection) and use contextual auto escaping to figure out the rest
--------------------------------------------------------------------------------
/test/htmlPaser.test.js:
--------------------------------------------------------------------------------
1 | describe('HTML parser', function() {
2 |
3 | it('should return null for empty string', function() {
4 | var el = html``;
5 |
6 | expect(el).to.be.empty;
7 | });
8 |
9 | it('should create a text node', function() {
10 | var el = html`foobar`;
11 |
12 | // correct node
13 | expect(el.nodeType).to.equal(3);
14 | expect(el.nodeValue).to.equal('foobar');
15 |
16 | // no extraneous side-effects
17 | expect(el.parentElement).to.be.null;
18 | });
19 |
20 | it('should create a single node', function() {
21 | var el = html``;
22 |
23 | // correct node
24 | expect(el.nodeName).to.equal('SPAN');
25 |
26 | // no extraneous side-effects
27 | expect(el.attributes.length, 'more than 1 attribute').to.equal(0);
28 | expect(el.children.length, 'more than 1 child').to.equal(0);
29 | expect(el.parentElement).to.be.null;
30 | expect(el.textContent).to.be.empty;
31 | });
32 |
33 | it('should create a single node when no closing tag is provided', function() {
34 | var el = html``;
35 |
36 | // correct node
37 | expect(el.nodeName).to.equal('SPAN');
38 |
39 | // no extraneous side-effects
40 | expect(el.attributes.length, 'more than 1 attribute').to.equal(0);
41 | expect(el.children.length, 'more than 1 child').to.equal(0);
42 | expect(el.parentElement).to.be.null;
43 | expect(el.textContent).to.be.empty;
44 | });
45 |
46 | it('should create a single node with attributes', function() {
47 | var el = html``;
48 |
49 | // correct node
50 | expect(el.nodeName).to.equal('INPUT');
51 |
52 | // correct attributes
53 | expect(el.attributes.length).to.equal(7);
54 | expect(el.type).to.equal('number');
55 | expect(el.min).to.equal('0');
56 | expect(el.max).to.equal('99');
57 | expect(el.name).to.equal('number');
58 | expect(el.id).to.equal('number');
59 | expect(el.className).to.equal('number-input');
60 | expect(el.disabled).to.equal(true);
61 |
62 | // no extraneous side-effects
63 | expect(el.children.length).to.equal(0);
64 | expect(el.parentElement).to.be.null;
65 | expect(el.textContent).to.be.empty;
66 | });
67 |
68 | it('should create a single node with children', function() {
69 | var el = html`Hello`;
70 |
71 | // correct container node
72 | expect(el.nodeName).to.equal('DIV');
73 | expect(el.attributes.length).to.equal(1);
74 | expect(el.className).to.equal('container');
75 | expect(el.children.length).to.equal(1);
76 | expect(el.parentElement).to.be.null;
77 | expect(el.textContent).to.equal('Hello');
78 |
79 | // correct row node
80 | var row = el.firstChild;
81 | expect(row.nodeName).to.equal('DIV');
82 | expect(row.attributes.length).to.equal(1);
83 | expect(row.className).to.equal('row');
84 | expect(row.children.length).to.equal(1);
85 | expect(row.parentElement).to.equal(el);
86 | expect(row.textContent).to.equal('Hello');
87 |
88 | // correct col node
89 | var col = row.firstChild;
90 | expect(col.nodeName).to.equal('DIV');
91 | expect(col.attributes.length).to.equal(1);
92 | expect(col.className).to.equal('col');
93 | expect(col.children.length).to.equal(1);
94 | expect(col.parentElement).to.equal(row);
95 | expect(col.textContent).to.equal('Hello');
96 |
97 | // correct leaf node
98 | var leaf = col.firstChild;
99 | expect(leaf.nodeName).to.equal('DIV');
100 | expect(leaf.attributes.length).to.equal(0);
101 | expect(leaf.children.length).to.equal(0);
102 | expect(leaf.parentElement).to.equal(col);
103 | expect(leaf.textContent).to.equal('Hello');
104 | });
105 |
106 | it('should create sibling nodes', function() {
107 | var nodes = html` `;
108 |
109 | // correct node
110 | expect(nodes).to.be.instanceof(DocumentFragment);
111 | expect(nodes.childNodes.length).to.equal(2);
112 |
113 | // correct first child
114 | var tr = nodes.querySelectorAll('tr')[0];
115 | expect(tr.nodeName).to.equal('TR');
116 | expect(tr.attributes.length, 'more than 1 attribute').to.equal(0);
117 | expect(tr.children.length, 'more than 1 child').to.equal(0);
118 | expect(tr.parentElement).to.be.null;
119 | expect(tr.textContent).to.be.empty;
120 |
121 | // correct second child
122 | var tr2 = nodes.querySelectorAll('tr')[1];
123 | expect(tr2.nodeName).to.equal('TR');
124 | expect(tr2.attributes.length, 'more than 1 attribute').to.equal(0);
125 | expect(tr2.children.length, 'more than 1 child').to.equal(0);
126 | expect(tr2.parentElement).to.be.null;
127 | expect(tr2.textContent).to.be.empty;
128 | });
129 |
130 | it('should execute a script tag', function() {
131 | var el = html``;
132 | document.body.appendChild(el);
133 |
134 | // correct node
135 | expect(el.nodeName).to.equal('SCRIPT');
136 | expect(el.attributes.length).to.equal(0);
137 | expect(el.children.length).to.equal(0);
138 | expect(el.textContent).to.equal('foo = "bar";');
139 |
140 | // script was executed
141 | expect(foo).to.equal('bar');
142 | });
143 | });
--------------------------------------------------------------------------------
/test/substitution.test.js:
--------------------------------------------------------------------------------
1 | describe('Substitution expressions', function() {
2 | var min = 0, max = 99, disabled = true, heading = 1, tag = 'span';
3 |
4 | it('should create a text node from a variable', function() {
5 | var el = html`${tag}`;
6 |
7 | // correct node
8 | expect(el.nodeType).to.equal(3);
9 | expect(el.nodeValue).to.equal('span');
10 |
11 | // no extraneous side-effects
12 | expect(el.parentElement).to.be.null;
13 | });
14 |
15 | it('should create a node from a variable', function() {
16 | var el = html`<${tag}>${tag}>`;
17 |
18 | // correct node
19 | expect(el.nodeName).to.equal('SPAN');
20 |
21 | // no extraneous side-effects
22 | expect(el.attributes.length, 'more than 1 attribute').to.equal(0);
23 | expect(el.children.length, 'more than 1 child').to.equal(0);
24 | expect(el.parentElement).to.be.null;
25 | expect(el.textContent).to.be.empty;
26 | });
27 |
28 | it('should add attribute names or values from variables', function() {
29 | var el = html``;
30 |
31 | // correct node
32 | expect(el.nodeName).to.equal('INPUT');
33 |
34 | // correct attributes
35 | expect(el.attributes.length).to.equal(7);
36 | expect(el.type).to.equal('number');
37 | expect(el.min).to.equal('0');
38 | expect(el.name).to.equal('number');
39 | expect(el.id).to.equal('number');
40 | expect(el.className).to.equal('number-input');
41 | expect(el.disabled).to.equal(true);
42 |
43 | // no extraneous side-effects
44 | expect(el.children.length).to.equal(0);
45 | expect(el.parentElement).to.be.null;
46 | expect(el.textContent).to.be.empty;
47 | });
48 |
49 | it('should skip empty attributes', function() {
50 | var emptyDisabled = false;
51 | var el = html``;
52 |
53 | // correct node
54 | expect(el.nodeName).to.equal('INPUT');
55 |
56 | // correct attributes
57 | expect(el.attributes.length).to.equal(6);
58 | expect(el.type).to.equal('number');
59 | expect(el.min).to.equal('0');
60 | expect(el.name).to.equal('number');
61 | expect(el.id).to.equal('number');
62 | expect(el.className).to.equal('number-input');
63 | expect(el.disabled).to.equal(false);
64 |
65 | // no extraneous side-effects
66 | expect(el.children.length).to.equal(0);
67 | expect(el.parentElement).to.be.null;
68 | expect(el.textContent).to.be.empty;
69 | });
70 |
71 | it('should skip non-valid attribute substituted names', function() {
72 | var nonValidAttrName = [];
73 | var el = html``;
74 |
75 | // correct node
76 | expect(el.nodeName).to.equal('DIV');
77 |
78 | // correct attributes
79 | expect(el.attributes.length).to.equal(0);
80 |
81 | // no extraneous side-effects
82 | expect(el.children.length).to.equal(0);
83 | expect(el.parentElement).to.be.null;
84 | expect(el.textContent).to.be.empty;
85 | });
86 |
87 | it('should move any children from a substituted node to the new node', function() {
88 | var el = html`Hello `;
89 |
90 | // correct heading node
91 | expect(el.nodeName).to.equal('H1');
92 | expect(el.attributes.length).to.equal(0);
93 | expect(el.children.length).to.equal(1);
94 | expect(el.parentElement).to.be.null;
95 | expect(el.textContent).to.equal('Hello');
96 |
97 | // correct span node
98 | var span = el.firstChild;
99 | expect(span.nodeName).to.equal('SPAN');
100 | expect(span.attributes.length, 'more than 1 attribute').to.equal(0);
101 | expect(span.children.length, 'more than 1 child').to.equal(0);
102 | expect(span.parentElement).to.equal(el);
103 | expect(span.textContent).to.equal('Hello');
104 | });
105 |
106 | it('should substitute in script tags', function() {
107 | var el = html``;
108 |
109 | // correct script node
110 | expect(el.nodeName).to.equal('SCRIPT');
111 | expect(el.attributes.length).to.equal(0);
112 | expect(el.children.length).to.equal(0);
113 | expect(el.parentElement).to.be.null;
114 | expect(el.textContent).to.equal('x = 99');
115 | });
116 |
117 | it('should allow optional attributes', function() {
118 | var el = html``;
119 |
120 | expect(el.hasAttribute('disabled')).to.be.true;
121 | expect(el.getAttribute('disabled')).to.equal('');
122 |
123 | var notDisabled = false;
124 | el = html``;
125 |
126 | expect(el.hasAttribute('disabled')).to.be.false;
127 | });
128 | });
--------------------------------------------------------------------------------
/test/xss.test.js:
--------------------------------------------------------------------------------
1 | var counter = 0;
2 |
3 | describe('XSS Attack Vectors', function() {
4 | // Modified XSS String
5 | // (Source: https://developers.google.com/closure/templates/docs/security#example)
6 | var xss = "javascript:/*/**/ /";
7 |
8 | afterEach(function() {
9 | counter++;
10 | });
11 |
12 | it('should prevent injection to element innerHTML', function() {
13 | var el = html`${xss}
`;
14 | document.body.appendChild(el);
15 | });
16 |
17 | it('should prevent injection to non-quoted element attributes', function() {
18 | var el = html``;
19 | document.body.appendChild(el);
20 | });
21 |
22 | it('should prevent injection to single quoted element attributes', function() {
23 | var el = html``;
24 | document.body.appendChild(el);
25 | });
26 |
27 | it('should prevent injection to double quoted element attributes', function() {
28 | var el = html``;
29 | document.body.appendChild(el);
30 | });
31 |
32 | it('should prevent injection as a javascript quoted string', function() {
33 | var el = html``;
34 | document.body.appendChild(el);
35 | });
36 |
37 | it('should prevent injection on one side of a javascript quoted expression', function() {
38 | var el = html``;
39 | document.body.appendChild(el);
40 | });
41 |
42 | it('should prevent injection into inlined quoted event handler', function() {
43 | var el = html`XSS <p> tag`;
44 | document.body.appendChild(el);
45 | el.click();
46 | });
47 |
48 | it('should prevent injection into quoted event handler', function() {
49 | var el = html`XSS <p> tag`;
50 | document.body.appendChild(el);
51 | el.click();
52 | });
53 |
54 | it('should prevent injection into CSS unquote property', function() {
55 | var el = html``;
56 | document.body.appendChild(el);
57 | });
58 |
59 | it('should prevent injection into CSS quoted property', function() {
60 | var el = html``;
61 | document.body.appendChild(el);
62 | });
63 |
64 | it('should prevent injection into CSS property of HTML style attribute', function() {
65 | var el = html``;
66 | document.body.appendChild(el);
67 | });
68 |
69 | it('should prevent injection into query params of HTML urls', function() {
70 | var el = html``;
71 | document.body.appendChild(el);
72 | el.click();
73 | });
74 |
75 | it('should prevent injection into HREF attribute of tag', function() {
76 | var el = html`XSS'ed Link`;
77 | document.body.appendChild(el);
78 | el.click();
79 | });
80 |
81 | it('should prevent against clobbering of /attributes/', function() {
82 | var el = html``;
86 | document.body.appendChild(el);
87 |
88 | // el.submit() does not trigger a submit event, so we need to click the submit button
89 | // @see http://stackoverflow.com/questions/11557994/jquery-submit-vs-javascript-submit
90 | el.querySelector('input[type="submit"]').click();
91 | });
92 |
93 | it('should prevent injection out of a tag name by throwing an error', function() {
94 | var func = function() {
95 | var el = html` `;
96 | document.body.appendChild(el);
97 | };
98 |
99 | expect(func).to.throw;
100 | });
101 |
102 | it('should prevent xss protocol URLs by rejecting them', function() {
103 | var el = html``;
104 | document.body.appendChild(el);
105 | el.click();
106 |
107 | expect(el.getAttribute('href')[0]).to.equal('#');
108 | });
109 |
110 | it('should not prevent javascript protocol if it was a safe string', function() {
111 | var value = 'foo/bar&baz/boo';
112 | var el = html``;
113 |
114 | expect(el.getAttribute('href')).to.equal('javascript:void(0);');
115 | });
116 |
117 | it('should prevent injection into uri custom attributes', function() {
118 | var el = html``
119 | document.body.appendChild(el);
120 | el.href = el.getAttribute('data-uri');
121 | el.click();
122 | });
123 |
124 | it('should entity escape URLs', function() {
125 | var value = 'foo/bar&baz/boo';
126 | var el = html``;
127 |
128 | expect(el.getAttribute('href')).to.equal('foo/bar&baz/boo');
129 | });
130 |
131 | it('should percent encode inside URL query', function() {
132 | var value = 'bar&baz=boo';
133 | var el = html``;
134 |
135 | expect(el.getAttribute('href')).to.equal('foo?q=bar%26baz%3Dboo');
136 | });
137 |
138 | it('should percent encode inside URL query and entity escape if not', function() {
139 | var value = 'bar&baz=boo';
140 | var el = html``;
141 |
142 | expect(el.getAttribute('href')).to.equal('foo/bar&baz=boo/bar?q=bar%26baz%3Dboo');
143 | });
144 |
145 | it('should reject a URL outright if it has the wrong protocol', function() {
146 | var protocol = 'javascript:alert(1337)';
147 | var value = '/foo&bar/bar';
148 | var el = html``;
149 |
150 | expect(el.getAttribute('href')[0]).to.equal('#');
151 | expect(el.getAttribute('href').indexOf('/bar')).to.equal(-1);
152 | });
153 |
154 | it('should allow a URL if it has a safe protocol', function() {
155 | var protocol = 'http://localhost:500';
156 | var value = '/foo?id=true';
157 | var el = html``;
158 |
159 | expect(el.getAttribute('href')).to.equal('http://localhost:500/bar/foo?id=true');
160 |
161 | var protocol = 'https://localhost:500';
162 | var el = html``;
163 |
164 | expect(el.getAttribute('href')).to.equal('https://localhost:500/bar/foo?id=true');
165 | });
166 |
167 | });
168 |
--------------------------------------------------------------------------------