如何避免HTML里面的JSON数据导致XSS安全问题?

在HTML里面,在<script>节点里面输出页面上需要用到的状态数据是个很常见、标准的用法,比如:

<script type="application/json">
    {"props":{"pageProps":{}},"page":"/","data":"<script type=\\"text/javascript\\" src=\\"validator.min.js\\"></script>"}
</script>

这样,在网页里面运行的JS就可以很容易读取这些数据,然后为UI加上更动态的功能。

但是,这样安全吗?

之前我以为是的,但是后来因为在页面上加了这样一段示例代码,整个页面就乱了。

<script type="text/javascript" src="validator.min.js"></script>

页面变成了这样:

很明显,是页面的HTML结构被破坏了,并且是JSON里面的数据导致的。

把页面上有问题的JSON检查了一下,发现浏览器把JSON里面字符串里的</script>当做当前<script>的结束标记,然后后面的JSON内容就被解释成了HTML来显示了。这个问题就来了,只要JSON的字符串类型的数据里有</script>就能把当前的JSON结束,然后开始新的代码执行,那么对于有恶意的攻击者来说,也就很容易在</script>之后加一段恶意的代码来做一些不好的事情!想想都吓人。

怎么处理呢?我想了几种办法:

1. 把JSON字符串做某种形式的编码,比如Base64编码,放到CDATA里面。

<script id="__NEXT_DATA__" type="text/javascript">
    <![CDATA[
        eyJwcm9wcyI6eyJwYWdlUHJvcHMiOnt9fSwicGFnZSI6Ii8iLCJkYXRhIjoiPHNjcmlwdCB0eXBlPVwidGV4dC9qYXZhc2NyaXB0XCIgc3JjPVwidmFsaWRhdG9yLm1pbi5qc1wiPjwvc2NyaXB0PiJ9
    ]]>
</script>

这种方式虽然安全了,但是缺点是Base64编码会导致这个数据变大,不太好!

2. 用一些轻量的压缩字符串的库来压缩,比如lz-string,然后把压缩数据仍旧放到CDATA里面。

<script id="__NEXT_DATA__" type="text/javascript">
    <![CDATA[
        N4IgDgTg9mDOIC5RgIYHMCmAFadHAF8CAacdDREAehFIBMUAXFSgHlgGMIBLMRgAkYBPMBgC8AHRCMMAD0ZUAVigBuKTjz5T+sCB0kg1AG24NGUCADoAttwB2lxbCkA+VlQ29GLkASA=
    ]]>
</script>

这种方式比纯粹用Base64的方式省一点大小,但还是比上面原始的体积大,并且最不友好的是:不管怎么做,这种编码过的数据实在不直观。

放弃上面的这两种方式!

继续研究,看看有没有业界标准的处理方式。最终找到了这篇文章:https://mathiasbynens.be/notes/etago里面引用了HTML标准里的这段话:

The easiest and safest way to avoid the rather strange restrictions described in this section is to always escape "<!-​-" as "<\!-​-", "<script" as "<\script", and "</script" as "<\/script" when these sequences appear in literals in scripts (e.g. in strings, regular expressions, or comments), and to avoid writing code that uses such constructs in expressions.

同时也推荐了几种编码的方式:

<script>
	// Using `unescape()`:
	document.write(unescape('<script>alert("wtf")%3C/script>')); // Überlame.
	// Using string concatenation:
	document.write('<script>alert("heh")<' + '/script>'); // Lame.
	// Using the octal escape sequence for the solidus character (/):
	document.write('<script>alert("hah")<\57script>'); // Lame, deprecated, and disallowed in ES5 strict mode.
	// Using the Unicode escape sequence:
	document.write('<script>alert("hoh")<\u002Fscript>'); // Lame.
	// Using the hexadecimal escape sequence:
	document.write('<script>alert("huh")<\x2Fscript>'); // Lame.
	// Simply escaping the solidus character:
	document.write('<script>alert("O HAI")<\/script>'); // Awesome!
</script>

这些方式里,最后四种替换起来最简单,并且如果是JSON数据的话,在做JSON.parse()的时候,都不需要再做额外的处理,不会影响使用的地方。其中,最后一种方式最优雅,那就用这种方式吧。

我的替换代码是这样:

JSON.stringify(state).replace(/<\/script>/g, '<\\/script>');

最终替换之后,HTML里的JSON是这样的:

<script type="application/json">
    {"props":{"pageProps":{}},"page":"/","data":"<script type=\\"text/javascript\\" src=\\"validator.min.js\\"><\/script>"}
</script>

页面就不再出问题了。

虽然HTML标准里面还有提到<\!-​-也会带来问题,但是实际试验的时候,浏览器里并没有发现什么问题,所以暂时就没有再多做额外的处理了。