全部文章

为什么React会检查element的$$typeof属性

2018-02-23 @sunderls

react xss

最近想尝试一个通过iframe把logic和view分离的实验,试了一下发现了react会给每一个element加上一个$$typeof的属性,觉得有点好奇就研究了一下。

jsx

https://reactjs.org/docs/introducing-jsx.html

这个大家肯定都不陌生,一般情况下React的项目都会使用jsx语法糖。

const button = <button className="a-button">click me </button>

通过babel等的编译,上述语法会编译成为大概如下的代码:

const button = React.createElement('button', {className: 'a-button'}, 'click me');

jsx并不会被浏览器厂商支持,也不会成为ECMAScript的标准,只是一种 XML-like syntax extension,但是已经成为一种事实上的标准,vue.js也进行了支持。其文档在这里: https://facebook.github.io/jsx/

element的内部表现

我们知道react使用了virtual dom,也就是一个json的树形结构来表示view组件,这个也比较直观。

我们尝试log一个react component试试。

console.log(<button className="a-button">click me</button>)

可以看到除了key, props, ref, type属性之外,还有一个$$typeof

JSON.stringify过后,$$typeof会消失,因为 JSON.stringify会忽略掉这些不能stringify的数据格式,包括Symbol, undefined, Function 等,我们试验一下


export default class Text extends React.Component {
    state = {
        view: null
    }

    componentDidMount() {
        const view = <button className="a-button">click me </button>;
        console.log(view);
        this.setState({
            view
        });
    }

    render() {
        return <div>{this.state.view}</div>
    }
};

这样的话是可以正常render出来的。

然后我们将其stringify然后parse

componentDidMount() {
    const view = JSON.parse(JSON.stringify(<button className="a-button">click me </button>));
    console.log(view);
    this.setState({
        view
    });
}

可以发现报错了。

如果手动添加一个$$typeof的话,可以发现可以跑起来。

componentDidMount() {
    const view = JSON.parse(JSON.stringify(<button className="a-button">click me </button>));
    console.log(view);
    view.$$typeof = Symbol.for('react.element');
    this.setState({
        view
    });
}

所以决定性的属性就是$$typeof

$$typeof是用来干嘛的。

从字段上可以看出,这是用来表明该object是react element的。

https://reactjs.org/blog/2015/12/18/react-components-elements-and-instances.html

All React elements require an additional $$typeof: Symbol.for('react.element') field declared on the object for security reasons. It is omitted in the examples above. This blog entry uses inline objects for elements to give you an idea of what’s happening underneath but the code won’t run as is unless you either add $$typeof to the elements, or change the code to use React.createElement() or JSX.

也就是说 $$typoef会在jsx中自动添加进来,用来解决安全问题。

神马安全问题?

具体issue在这里:

Use a Symbol to tag every ReactElement

How Much XSS Vulnerability Protection is React Responsible For?

XSS via a spoofed React element

Improperly validated fields allows injection of arbitrary HTML via spoofed React objects

具体bug有点长,总结如下:

  1. 有个网站允许用户输入,比如就叫name吧。
  2. 前端渲染的时候,从服务器API获取数据,其中有这个name字段,比如 user: {name: 'some name'}, 然后假设了name是个字符串,就render了。React.creatElement('div', null, user.name)
  3. 但是API没做太多验证或者其他bug,用户可以输入一个json字符串,导致API的name字段可能是一个Object
    1. 比如用户输入了 "{"a": "33"}"
    2. API返回了 user: {name: {a: '33'}}
    3. 实际React渲染的时候就变成了 React.creatElement('div', null, {a: '33'})
  4. 而React的第三个参数是可以自定一个component,所以用户实际上就可以输入一个component!!
    1. 比如大概这样的object string, "{"type": "div", "_isReactElement": true, "props": {"dangerouslySetInnerHTML": {"__html": "<script>alert('hey')</script>"}}}"
    2. 注意上面的数据用了 _isReactElement:true来骗过了React告诉它这是一个合法的react element
    3. 然后dangerouslySetInnerHTML又允许插入raw的html片段
  5. 至此XSS注入完成。

这更多的难道不是服务器API的问题?

正如这个issue所说,这个更多的是服务器API的设计问题,应该避免返回不必要的object json。

但是做为React,为了降低这种安全风险,所以在判断是否是合法的React element的时候,对children增加了validation。

具体如何实现的话这个issue中可以查看到,总结一下,问题在于{props.xxx]}展开的时候可能遇到一个伪装的component。oh

var data = JSON.parse(decodeURI(location.search.substr(1)));

function Foo(props) {
  return <div><div {...props} /><span>{props.content}</span></div>;
}

ReactDOM.render(<Foo {...data} />, container);

如何解决?

最终采用的是symbol的方式,在判断是否合法element的时候,检查了 $$typeof属性:

var TYPE_SYMBOL = (typeof Symbol === 'function' && Symbol.for &&
    Symbol.for('react.element')) || 0xeac7;

ReactElement.isValidElement = function(object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.$$typeof === TYPE_SYMBOL
  );
};

然后在createElement的时候增加了 $$typeof: TYPE_SYMBOL,这样简单的JSON 拷贝就不会通过isValidElment验证了,因为Symbol不是字符串,无法伪装。

但是看了上述代码,可以发现在没有Symbol的情况下,react fallback到了 0xeac7,如果我把window.Symbol设置为null,然后把$$typeof设置为0xeac7的话,就可以通过验证了?

事实上是的,只不过你需要在react启动之前把Symbol覆盖掉,

<script>
window.Symbol = null;
</script>
<script src="app.js"></script>

然后:

componentDidMount() {
    const view = JSON.parse(JSON.stringify(<button className="a-button">click me </button>));
    console.log(view);
    view.$$typeof = 0xeac7;
    this.setState({
        view
    });
}

嗯,这样也能通过!!

那如果浏览器环境不支持window.Symbol,那是不是这个漏洞依然存在呢?你答对了,这个bug依然存在!!所以如果你用react,一定要加入Symbol polyfill!! 至于为啥会用一个固定number,而不是Function或者Math.random、因为:

The fallback solution is a plain well-known number. This makes it unsafe with regard to the XSS issue described in #3473. We could have used a much more convoluted solution to protect against JSON specifically but that would require some kind of significant coordination, or change the check to do a typeof element.$$typeof === 'function' check which would not make it unique to React. It seems cleaner to just use a fixed number since the protection is just a secondary layer anyway. I'm not sure if this is the right tradeoff.

https://github.com/facebook/react/pull/4832/files#r39641869

就是说如果使用了 Symbol.for的话,得到的值是global的,无论你在iframe还是在worker里面,得到的值是一样的!!!

0xeac7?

为啥是这个数,因为看起来像 React