├── .npmignore ├── .gitignore ├── .babelrc ├── src ├── index.js ├── __tests__ │ ├── __snapshots__ │ │ └── toReactElements.js.snap │ ├── parse.js │ ├── toReactElements.js │ └── propertyTests.js ├── renameFunc.js ├── ReactSafeHtml.js ├── parse.js ├── toReactElements.js └── components │ └── index.js ├── examples └── echo │ ├── .babelrc │ ├── index.html │ ├── package.json │ ├── index.js │ └── yarn.lock ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.swp 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./ReactSafeHtml'); 2 | 3 | -------------------------------------------------------------------------------- /examples/echo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/echo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/toReactElements.js.snap: -------------------------------------------------------------------------------- 1 | exports[`test toReactElements basic 1`] = ` 2 |
3 | 4 | a child 5 | 6 |
7 | `; 8 | 9 | exports[`test toReactElements override text 1`] = ` 10 | 11 | 12 |

14 | a child 15 |

16 |
17 |
18 | `; 19 | -------------------------------------------------------------------------------- /src/renameFunc.js: -------------------------------------------------------------------------------- 1 | const getOwn = Object.getOwnPropertyDescriptor; 2 | 3 | function renameFunc(func, name) { 4 | if (!getOwn) { 5 | return 6 | } 7 | 8 | const desc = getOwn(func, 'name'); 9 | 10 | if (desc && desc.configurable) { 11 | Object.defineProperty(func, 'name', { 12 | ...desc, 13 | value: name, 14 | }); 15 | } 16 | } 17 | 18 | module.exports = renameFunc; 19 | -------------------------------------------------------------------------------- /examples/echo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "echo", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "browserify ./index.js -t babelify -o bundle.js", 9 | "watch": "watchify ./index.js -t babelify -o bundle.js" 10 | }, 11 | "author": "Frankie Bagnardi ", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babelify": "^7.2.0", 15 | "browserify": "^15.2.0", 16 | "react": "^16.2.0", 17 | "react-dom-server": "^0.0.5", 18 | "watchify": "^3.10.0" 19 | }, 20 | "dependencies": { 21 | "react-dom": "^16.2.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/__tests__/parse.js: -------------------------------------------------------------------------------- 1 | var parse = require('../parse.js'); 2 | //var {expect} = require('chai'); 3 | var log = (x) => console.log(JSON.stringify(x, null, 2)); 4 | 5 | describe('parse', () => { 6 | it('parses a simple string', () => { 7 | var res = parse('foo'); 8 | log(res); 9 | expect(res).toEqual({ 10 | type: 'div', props: {}, children: [ 11 | {type: 'x', props: {a: 'b'}, children: ['foo']} 12 | ], 13 | }); 14 | }); 15 | 16 | it(`parses nested elements`, () => { 17 | var res = parse(`
c1
c2
`); 18 | expect(res).toEqual({ 19 | type: 'div', props: {}, children: [ 20 | {type: 'div', props: {}, children: [ 21 | 'c1', 22 | { type: 'blockquote', props: {}, children: ['c2'] }, 23 | ]} 24 | ], 25 | }); 26 | }); 27 | }); 28 | 29 | -------------------------------------------------------------------------------- /src/__tests__/toReactElements.js: -------------------------------------------------------------------------------- 1 | var toReactElements = require('../toReactElements.js'); 2 | var log = (x) => console.log(JSON.stringify(x, null, 2)); 3 | var basicTypes = { 4 | div: () => ({}), 5 | }; 6 | 7 | it('toReactElements basic', () => { 8 | var res = toReactElements({ 9 | type: 'div', props: {}, children: [ 10 | {type: 'not-allowed', props: {skipThis: true}, children: ['a child']} 11 | ], 12 | }, basicTypes); 13 | expect(res).toMatchSnapshot(); 14 | }); 15 | 16 | it('toReactElements override text', () => { 17 | var types = { 18 | '#text': (text) => ({type: 'p', props: {'data-foo': 'bar', children: [text]}}), 19 | }; 20 | var res = toReactElements({ 21 | type: 'div', props: {}, children: [ 22 | {type: 'not-allowed', props: {skipThis: true}, children: ['a child']} 23 | ], 24 | }, types); 25 | log(res); 26 | expect(res).toMatchSnapshot(); 27 | }); 28 | 29 | -------------------------------------------------------------------------------- /src/ReactSafeHtml.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var PropTypes = require('prop-types'); 3 | var parse = require('./parse'); 4 | var toReactElements = require('./toReactElements.js'); 5 | var components = require('./components'); 6 | 7 | class ReactSafeHtml extends React.Component { 8 | static propTypes = { 9 | html: PropTypes.string, 10 | components: PropTypes.object, // e.g. {div: Component} 11 | }; 12 | 13 | static defaultProps = { 14 | components: components.makeElements(components.standardAllowedProps), 15 | }; 16 | 17 | shouldComponentUpdate(nextProps) { 18 | return nextProps.html !== this.props.html; 19 | } 20 | 21 | render() { 22 | var parsed = parse(this.props.html + ' '); 23 | var tree = toReactElements(parsed, this.props.components); 24 | return tree; 25 | } 26 | }; 27 | 28 | ReactSafeHtml.components = components; 29 | 30 | module.exports = ReactSafeHtml; 31 | 32 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | var cheerio = require('cheerio'); 2 | var htmlparser = require('htmlparser2'); 3 | var {AllHtmlEntities} = require('html-entities'); 4 | var entities = new AllHtmlEntities(); 5 | 6 | type ReactSafeNode = { 7 | type: string; 8 | props: {}; 9 | children: Array 10 | }; 11 | 12 | module.exports = function parse(str): Array { 13 | var result = {type: 'div', props: {}, children: []}; 14 | var stack = [result]; 15 | var parser = new htmlparser.Parser({ 16 | onopentag: (name, attribs) => { 17 | var element:ReactSafeNode = {type: name, props: attribs, children: []}; 18 | stack[stack.length - 1].children.push(element); 19 | stack.push(element); 20 | }, 21 | ontext: (text) => { 22 | stack[stack.length - 1].children.push(entities.decode(text)); 23 | }, 24 | onclosetag: (name) => { 25 | stack.pop(); 26 | }, 27 | }); 28 | parser.write(str); 29 | parser.end(); 30 | return result; 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/toReactElements.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | module.exports = function toReactElements(node, components, key = 'default_key') { 4 | if (typeof node === 'string') { 5 | if (components['#text']) { 6 | var result = components['#text'](node); 7 | if (!result || !result.type) { 8 | console.error('react-safe-html #text component didn\'t return a react element'); 9 | return node; 10 | } 11 | return result; 12 | } 13 | else { 14 | return node; 15 | } 16 | } 17 | 18 | var children = node.children; 19 | 20 | if (Array.isArray(children)) { 21 | children = node.children.map((child, i) => { 22 | var element = toReactElements(child, components, i); 23 | return element; 24 | }); 25 | } 26 | 27 | if (!Array.isArray(children)) { 28 | children = [children]; 29 | } 30 | 31 | var resNode = null; 32 | var type = components[node.type]; 33 | 34 | if (type) { 35 | var props = Object.assign({}, node.props, { key }); 36 | resNode = React.createElement(type, props, children); 37 | } else { 38 | resNode = React.createElement('span', { key }, children); 39 | } 40 | return resNode; 41 | } 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-safe-html", 3 | "version": "0.6.1", 4 | "description": "render user provided html safely by using react components", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "test": "lib/__test__" 8 | }, 9 | "scripts": { 10 | "test": "jest", 11 | "build": "babel src --out-dir lib --ignore src/__tests__", 12 | "watch": "npm run build -- --watch" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/brigand/react-safe-html.git" 17 | }, 18 | "keywords": [ 19 | "react", 20 | "html", 21 | "safe", 22 | "security", 23 | "react-component" 24 | ], 25 | "author": "Frankie Bagnardi ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/brigand/react-safe-html/issues" 29 | }, 30 | "homepage": "https://github.com/brigand/react-safe-html#readme", 31 | "devDependencies": { 32 | "babel-cli": "^6.16.0", 33 | "babel-core": "^6.17.0", 34 | "babel-preset-es2015": "^6.16.0", 35 | "babel-preset-react": "^6.16.0", 36 | "babel-preset-stage-1": "^6.16.0", 37 | "chai": "^3.5.0", 38 | "enzyme": "^2.4.1", 39 | "jest": "^16.0.1", 40 | "jsdom": "^9.6.0", 41 | "mocha": "^3.1.1", 42 | "prop-types": "^15.6.0", 43 | "react": "^16.2.0", 44 | "react-dom": "^16.2.0" 45 | }, 46 | "dependencies": { 47 | "cheerio": "^0.22.0", 48 | "html-entities": "^1.2.0", 49 | "htmlparser2": "^3.9.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-safe-html allows you to render user provided html (e.g. from ckeditor) safely. You choose how each element 2 | renders and which attributes get passed through. It has defaults for basic elements and attributes but is fully customizable. 3 | 4 | > Note: you may wish to use a more popular library like [react-html-parse](https://www.npmjs.com/package/react-html-parse) or [safe-html](https://www.npmjs.com/package/safe-html). 5 | 6 | It uses a fast but flexible parser (htmlparser2) and implements shouldComponentUpdate for performance. 7 | 8 | ## status: alpha 9 | 10 | ## Install 11 | 12 | You can install it with npm: 13 | 14 | ```sh 15 | npm install --save react-safe-html 16 | ``` 17 | 18 | And require it: 19 | 20 | ```js 21 | var ReactSafeHtml = require('react-safe-html'); 22 | // ... 23 | 24 | ``` 25 | 26 | 27 | ## Customization 28 | 29 | You can create a custom element set to allow. 30 | 31 | ```js 32 | // the default allowed components 33 | var components = ReactSafeHtml.components.makeElements({}); 34 | ``` 35 | 36 | The argument is a mapping of allowed properties for all elements, for example you may pass `{style: true}` to allow 37 | style props on all elements. 38 | 39 | You may also pass a function which gets the attribute value and returns a tupple of `[propName, propValue]`. 40 | This is the same as `{style: true}`: 41 | `{style: (theStyleString) => ['style', theStyleString]}`. 42 | 43 | ### Adding/replacing elements 44 | 45 | You may want to add extra elements to the allowed set, or remove some. 46 | 47 | `createSimpleElement` takes an object like the one described above. 48 | 49 | ```js 50 | delete components.img; 51 | components.input = ReactSafeHtml.components.createSimpleElement('input', { 52 | value: true, 53 | placeholder: true, 54 | 'tab-index': (index) => ['tabIndex', index], 55 | }); 56 | ``` 57 | 58 | You can override the behavior for text nodes with a special component type `'#text'`. 59 | 60 | ```jsx 61 | components['#text'] = (string) =>

{string}

; 62 | ``` 63 | 64 | When you're done customizing, pass it as an extra prop to `ReactSafeHtml`. 65 | 66 | ```js 67 | 68 | ``` 69 | 70 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | const renameFunc = require('../renameFunc') 3 | 4 | exports.createSimpleElement = createSimpleElement; 5 | exports.makeElements = makeElements; 6 | 7 | function createSimpleElement(tag, allowed, extraProps={}) { 8 | const C = (props) => { 9 | var resultProps = {}; 10 | Object.keys(allowed).forEach((allowedKey) => { 11 | if (props[allowedKey]) { 12 | var result = props[allowedKey]; 13 | 14 | if (typeof allowed[allowedKey] === 'function') { 15 | [allowedKey, result] = allowed[allowedKey](result); 16 | } 17 | 18 | resultProps[allowedKey] = result; 19 | } 20 | }); 21 | 22 | Object.assign(resultProps, extraProps); 23 | 24 | return React.createElement(tag, resultProps, ...(props.children||[])); 25 | }; 26 | 27 | renameFunc(C, `SafeHtml__${tag}`); 28 | 29 | return C; 30 | } 31 | 32 | var standardAllowedProps = { 33 | }; 34 | 35 | exports.standardAllowedProps = standardAllowedProps; 36 | 37 | function makeElements(standardAllowedProps={}) { 38 | var elements = {}; 39 | var makeSimple = (tag, extraAllowededProps={}, extraProps={}) => ( 40 | createSimpleElement(tag, {...standardAllowedProps, ...extraAllowededProps}, extraProps) 41 | ) 42 | var makeSimpleAndAssign = (tag, ...args) => elements[tag] = makeSimple(tag, ...args); 43 | 44 | // Basic elements 45 | makeSimpleAndAssign('div'); 46 | makeSimpleAndAssign('span'); 47 | makeSimpleAndAssign('a', { 48 | href: (value) => { 49 | var proto = value.split(':').shift(); 50 | if (proto !== 'http' && proto !== 'https' && proto !== 'ftp') { 51 | value = undefined; 52 | } 53 | return ['href', value]; 54 | } 55 | }); 56 | makeSimpleAndAssign('img', { 57 | width: true, 58 | height: true, 59 | src: true, 60 | }); 61 | makeSimpleAndAssign('p'); 62 | 63 | // Style 64 | makeSimpleAndAssign('b'); 65 | makeSimpleAndAssign('strong'); 66 | makeSimpleAndAssign('i'); 67 | makeSimpleAndAssign('em'); 68 | makeSimpleAndAssign('u'); 69 | makeSimpleAndAssign('strike'); 70 | 71 | // Tables 72 | makeSimpleAndAssign('table'); 73 | makeSimpleAndAssign('tr'); 74 | makeSimpleAndAssign('td'); 75 | makeSimpleAndAssign('tbody'); 76 | makeSimpleAndAssign('thead'); 77 | 78 | // Headers 79 | ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7'].forEach((tag) => makeSimpleAndAssign(tag)); 80 | 81 | return elements; 82 | } 83 | 84 | -------------------------------------------------------------------------------- /examples/echo/index.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var { renderToString } = require('react-dom/server'); 4 | var ReactSafeHtml = require('../../'); 5 | var components = ReactSafeHtml.components.makeElements({}); 6 | // delete components.span; 7 | 8 | components.div = ReactSafeHtml.components.createSimpleElement('div', {}); 9 | components.blockquote = ReactSafeHtml.components.createSimpleElement('blockquote', {}); 10 | components.em = ReactSafeHtml.components.createSimpleElement('em', {}); 11 | components.p = ReactSafeHtml.components.createSimpleElement('p', {}); 12 | components.strong = ReactSafeHtml.components.createSimpleElement('strong', {}); 13 | components.ul = ReactSafeHtml.components.createSimpleElement('ul', {}); 14 | components.ol = ReactSafeHtml.components.createSimpleElement('ol', {}); 15 | components.li = ReactSafeHtml.components.createSimpleElement('li', {}); 16 | //components.span = ReactSafeHtml.components.createSimpleElement('span', { 17 | // class: true 18 | //}); 19 | const INITIAL_HTML = ` 20 |
21 |
1
22 | 23 |
24 |
hello bar
25 |

this should show

26 |
27 |
28 | `.trim(); 29 | 30 | class App extends React.Component { 31 | constructor() { 32 | super(); 33 | this.state = { 34 | html: INITIAL_HTML, 35 | }; 36 | } 37 | 38 | componentDidMount() { 39 | this.componentDidUpdate(); 40 | } 41 | 42 | componentDidUpdate() { 43 | var inp = document.querySelector('.demo-a'); 44 | var out = document.querySelector('.demo-b'); 45 | 46 | out.textContent = inp.innerHTML; 47 | } 48 | 49 | render() { 50 | var display = ; 51 | return ( 52 |
53 |
54 |