├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── package-lock.json ├── package.json ├── src └── index.js ├── test └── index.js ├── testem.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | test/build.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | testem.json 2 | test 3 | src 4 | .babelrc 5 | webpack.config.js -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Łukasz Makuch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snabbdom-Signature 2 | Protects your app against vnode injection. 3 | ```javascript 4 | const text = userInput.potentiallyMaliciousInput; 5 | // Thanks to Snabbdom-Signature this is XSS-free. 6 | const vnode = h('p', text); 7 | ``` 8 | 9 | ## The problem Snabbdom-Signature solves 10 | Snabbdom vnodes are just data structures. 11 | It's impossible to distinguish vnodes created by the programmer from user-supplied objects. 12 | Malicious vnodes may come from sources like web requests or document-oriented databases. 13 | It is a type of [XSS attacks](https://www.owasp.org/index.php/Cross-site_Scripting_(XSS)). 14 | 15 | ## How does Snabbdom-Signature work? 16 | Snabbdom-Signature ships with two essential parts. 17 | 1. A `h` vnode factory function which marks the vnodes it creates with a [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). 18 | ```javascript 19 | const h = snabbdomSignature.signingH(require('snabbdom/h').default); 20 | const vnode = h('p', text); // {sel: "p", data: {snabbdom_signature: Symbol(snabbdom_signature)}, /* ... */ } 21 | ``` 22 | Please bear in mind that in order for this mechanism to work, __the browser must support Symbols__. 23 | 24 | 2. A module which allows DOM elements to be created only based on signed (marked with a Symbol) vnodes. 25 | ```javascript 26 | const patch = snabbdom.init([ 27 | snabbdomSignature.denyUnlessSigned, 28 | // other modules 29 | ]); 30 | ``` 31 | Since now the `patch` function throws an error (`Error: Patching with a vnode which is not correctly signed!`) when it encounters an unsigned vnode. 32 | 33 | ## Getting Snabbdom-Signature 34 | First, install the package. 35 | ``` 36 | npm i snabbdom-signature 37 | ``` 38 | Then, add the `denyUnlessSigned` module to your snabbdom init call and get the vnode signing version of the `h` function. 39 | ```javascript 40 | const snabbdom = require('snabbdom'); 41 | // Include the Snabbdom-Signature package 42 | const snabbdomSignature = require('snabbdom-signature'); 43 | const patch = snabbdom.init([ 44 | // Add the snabbdomSignature.denyUnlessSigned module 45 | // to make sure that every vnode has been created by the programmer 46 | // and not a malicious user. 47 | snabbdomSignature.denyUnlessSigned, 48 | // other modules 49 | ]); 50 | // This helper function signs vnodes. 51 | // Use it like you use the default h helper. 52 | const h = snabbdomSignature.signingH(require('snabbdom/h').default); 53 | ``` 54 | ## Transferring vnodes (postMessage, Worker, JSON etc.) 55 | 1. Remove the signature from a tree 56 | 57 | Let's say `signedVnode` is a complex tree with many signed vnodes. 58 | 59 | In order to be able to do things like sending vnodes to another window, we need to remove all the signatures, because Symbols don't survive structured cloning. 60 | ```javascript 61 | const snabbdomSignature = require('snabbdom-signature'); 62 | const removeSignature = snabbdomSignature.removeSignature; 63 | 64 | // This form is ready for serialization/structured cloning. 65 | const unsigned = removeSignature(signedVnode); 66 | ``` 67 | Because signature validation usually happens during the patching phase, there's a chance that the tree we pass to the `removeSignature` function consists of an injected, malicious vnode. That's why __the removeSignature function verifies the signature and throws an error if it's invalid__. You can be sure that if the signature is removed successfully, you can trust this tree again. 68 | 69 | 2. Sign an unsigned tree coming from a trusted source 70 | 71 | In the previous step we built a potentially dangerous tree of vnodes. Then, we used the `removeSignature` function to remove automatically added signatures (after verifying if all nodes were correctly signed). 72 | 73 | Let's say that `unsigned` is that signature-less tree received from a trusted source. In order to be able to use it, we need to add all the signatures again. 74 | ```javascript 75 | const snabbdomSignature = require('snabbdom-signature'); 76 | const sign = snabbdomSignature.sign; 77 | 78 | // This form is accepted by the patch function. 79 | const signedAgain = sign(unsigned); 80 | ``` 81 | __Please, make sure that you're not calling `trust` on trees coming from untrusted sources, like user-supplied JSON etc.__ Otherwise, there's a risk of XSS. Also, make sure that the unsigned node you're trusting has been somehow verified earlier, either by the `removeSignature` function or by successfully `patch`ing the DOM with it. 82 | 83 | ## Development 84 | If you found a security vulnerability in this package, please write to me at kontakt@lukaszmakuch.pl. 85 | 86 | If you know a way to improve the performance or fix a bug which is not related to security, feel free to create an issue or submit a pull request. 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snabbdom-signature", 3 | "version": "0.0.3", 4 | "description": "Protects against vnode injection.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "testem", 8 | "build": "rm -rf dist && babel src -d dist", 9 | "build-tests": "webpack --config webpack.config.js", 10 | "clean-up-tests": "rm test/build.js", 11 | "prepublishOnly": "npm run build" 12 | }, 13 | "keywords": [ 14 | "snabbdom", 15 | "vdom", 16 | "security", 17 | "xss" 18 | ], 19 | "author": "Łukasz Makuch (https://lukaszmakuch.pl)", 20 | "license": "MIT", 21 | "dependencies": {}, 22 | "devDependencies": { 23 | "snabbdom": "^0.7.3", 24 | "testem": "^2.14.0", 25 | "webpack-cli": "^3.2.3", 26 | "webpack": "^4.29.6", 27 | "@babel/cli": "^7.2.3", 28 | "@babel/core": "^7.3.4", 29 | "@babel/preset-env": "^7.3.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const signatureKey = 'snabbdom_signature'; 2 | 3 | const signature = Symbol.for(signatureKey); 4 | 5 | export const signedData = {[signatureKey]: signature}; 6 | 7 | const signData = data => Object.assign({}, data, signedData); 8 | 9 | const isPlainTextNode = vnode => ( 10 | (vnode.children === undefined) 11 | && (vnode.data === undefined) 12 | && (vnode.key === undefined) 13 | ); 14 | 15 | const isSigned = vnode => { 16 | return vnode.data 17 | ? (vnode.data[signatureKey] === signature) 18 | : isPlainTextNode(vnode); 19 | }; 20 | 21 | const removeSignatureFromData = data => { 22 | return Object.assign({}, data, {[signatureKey]: undefined}); 23 | }; 24 | 25 | const isPrimitive = a => ['string', 'number'].includes(typeof a); 26 | 27 | export const signingH = defaultH => (sel, b, c) => { 28 | if (c !== undefined) { 29 | // a - sel, b - data, c - primitive/child/children 30 | return defaultH(sel, signData(b), c); 31 | } else if (b === undefined) { 32 | // a - sel 33 | return defaultH(sel, signedData); 34 | } else if (Array.isArray(b) || b.sel || isPrimitive(b)) { 35 | // a - sel, b - primitive/child/children 36 | return defaultH(sel, signedData, b); 37 | } else { 38 | // a - sel, b - data 39 | return defaultH(sel, signData(b)); 40 | } 41 | }; 42 | 43 | const denyUnlessSignedHook = (oldVnode, vnode) => { 44 | if (!isSigned(vnode)) { 45 | throw new Error('Patching with a vnode which is not correctly signed!'); 46 | } 47 | }; 48 | 49 | export const denyUnlessSigned = { 50 | create: denyUnlessSignedHook, 51 | update: denyUnlessSignedHook, 52 | }; 53 | 54 | const processDataRecursively = (checkIfValidVnode, dataProcessFn) => { 55 | const fn = tree => { 56 | checkIfValidVnode(tree); 57 | const data = dataProcessFn(tree.data); 58 | const children = tree.children ? tree.children.map(fn) : undefined; 59 | return Object.assign({}, tree, {data, children}); 60 | }; 61 | return fn; 62 | }; 63 | 64 | export const removeSignature = processDataRecursively( 65 | (vnode) => { 66 | if (!isSigned(vnode)) { 67 | throw new Error('Unable to remove the signature, because the vnode is not signed.'); 68 | } 69 | }, 70 | removeSignatureFromData 71 | ); 72 | 73 | export const sign = processDataRecursively(() => {}, signData); -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const snabbdom = require('snabbdom'); 3 | const snabbdomSignature = require('./../dist/index'); 4 | const signingH = snabbdomSignature.signingH; 5 | const removeSignature = snabbdomSignature.removeSignature; 6 | const sign = snabbdomSignature.sign; 7 | const patch = snabbdom.init([ 8 | snabbdomSignature.denyUnlessSigned, 9 | require('snabbdom/modules/class').default, 10 | require('snabbdom/modules/props').default, 11 | require('snabbdom/modules/style').default, 12 | require('snabbdom/modules/eventlisteners').default, 13 | ]); 14 | const defaultH = require('snabbdom/h').default; 15 | 16 | const h = signingH(defaultH); 17 | 18 | const expectedError = /not correctly signed/; 19 | const complexVnode = /* sel, data, children */ h('p', {style: {fontWeight: 'bold'}}, [ 20 | // sel, data, child 21 | h('span', {}, /* sel, primitive */h('b', 'text')), 22 | // primitive 23 | 'text', 24 | // sel 25 | h('u'), 26 | // sel, data 27 | h('i', {style: {fontWeight: 'bold'}}) 28 | ]); 29 | const complexVnodeHTML = '

texttext

'; 30 | 31 | describe('snabbdom-signature', () => { 32 | 33 | it('does not change the regular workflow', () => { 34 | const container = document.createElement('div'); 35 | const patched = patch(container, complexVnode); 36 | assert.equal(patched.elm.outerHTML, complexVnodeHTML); 37 | }); 38 | 39 | xit('exports a piece of signed data', () => { 40 | const manuallySigned = defaultH('b', snabbdomSignature.signedData, 'test'); 41 | const container = document.createElement('div'); 42 | const patched = patch(container, manuallySigned); 43 | assert.equal(patched.elm.outerHTML, 'test'); 44 | }); 45 | 46 | describe('removing signatures', () => { 47 | 48 | it('gives a way to remove the signature', () => { 49 | const unsigned = removeSignature(complexVnode); 50 | const assertNoSignature = tree => { 51 | assert((tree.data === undefined) || (tree.data.snabbdom_signature === undefined)); 52 | if (tree.children) tree.children.forEach(assertNoSignature); 53 | } 54 | assertNoSignature(unsigned); 55 | assert.throws(() => { 56 | const container = document.createElement('div'); 57 | patch(container, unsigned); 58 | }, expectedError); 59 | }); 60 | 61 | const assertImpossibleToRemoveSignature = (tree) => () => { 62 | assert.throws(() => removeSignature(tree), /is not signed/); 63 | }; 64 | 65 | it('throws an error when the parent node is not signed', 66 | assertImpossibleToRemoveSignature( 67 | defaultH('p', [ 68 | 'unsigned, but just text', 69 | h('span', 'signed') 70 | ]) 71 | ) 72 | ); 73 | 74 | it('throws an error when a child is not signed', 75 | assertImpossibleToRemoveSignature( 76 | h('p', [ 77 | 'unsigned, but just text', 78 | defaultH('span', 'unsigned') 79 | ]) 80 | ) 81 | ); 82 | 83 | }); 84 | 85 | it('gives a way to trust untrusted vnodes', () => { 86 | const signedAgain = sign(removeSignature(complexVnode)); 87 | const container = document.createElement('div'); 88 | const patched = patch(container, signedAgain); 89 | assert.equal(patched.elm.outerHTML, complexVnodeHTML); 90 | }); 91 | 92 | it('does NOT allow to create a DOM element based on an unsigned vnode', () => { 93 | assert.throws(() => { 94 | const unsignedVnode = {sel: 'span', data: {}, text: 'a'}; 95 | const container = document.createElement('div'); 96 | patch(container, unsignedVnode); 97 | }, expectedError); 98 | }); 99 | 100 | it('does NOT allow to update a DOM element based on an unsigned vnode', () => { 101 | assert.throws(() => { 102 | const signedVnode = h('span', 'aa'); 103 | const unsignedVnode = {sel: 'span', data: {}, text: 'ab'}; 104 | const container = document.createElement('div'); 105 | patch(patch(container, signedVnode), unsignedVnode); 106 | }, expectedError); 107 | }); 108 | 109 | }); -------------------------------------------------------------------------------- /testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "src_files": [ 3 | ], 4 | "serve_files": [ 5 | "test/build.js" 6 | ], 7 | "before_tests": "npm run build && npm run build-tests", 8 | "on_exit": "npm run clean-up-tests" 9 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'development', 3 | entry: './test/index.js', 4 | output: { 5 | path: __dirname + "/test", 6 | filename: 'build.js' 7 | } 8 | }; --------------------------------------------------------------------------------