JSON 转义不只是反斜杠:Unicode、代理项与嵌入故障
JSON 的字符串转义规则从 2017 年起就被冻结了(RFC 8259、ECMA-404 第二版)。Crockford 当年故意把字符串语法定得比 JavaScript 还窄,这个选择带来的副作用,最常出现在跨系统数据传递时——双方对“字符串”的理解只差那么一点点。
转义表
JSON 字符串只允许少数几个转义序列,RFC 8259 §7 完整列出来了:
| 转义 | 含义 |
|---|---|
\" |
双引号 |
\\ |
反斜杠 |
\/ |
正斜杠(可选,下面解释) |
\b |
退格,U+0008 |
\f |
进纸符,U+000C |
\n |
换行,U+000A |
\r |
回车,U+000D |
\t |
水平制表,U+0009 |
\uXXXX |
BMP 内任意 code point |
不在这张表里的转义都是非法的。\a、\v、\x41——严格解析器一律拒绝。JavaScript 字符串字面量支持这些,JSON 故意不支持。
另一条规范严格的地方是:U+0000 到 U+001F 的控制字符在字符串里必须转义。哪怕是字面的换行字节出现在字符串中间也是解析错误(虽然在编辑器里看不见)。这是 JSON 比人想得更挑剔的少数几处之一。
为什么 \/ 存在
正斜杠转义是允许的、但永远不必需。"https://example.com" 和 "https:\/\/example.com" 是等价的 JSON。
它存在的原因是:HTML 允许 </ 在字符串字面量里结束 <script> 标签。如果你在服务端渲染时把 JSON 直接嵌入 <script> 块、又没做转义,那么攻击者控制的字符串里夹一个 </script> 就能跳出脚本上下文。把 / 转义成 \/ 的编码器一举堵死这一类注入。PHP 的 json_encode 默认这么做,许多注重 CSP 的库也这么做。
如果你在输出里看到 \/ 觉得奇怪,原因就在这里。
非 ASCII 与 \uXXXX
\uXXXX 让你用四个十六进制位写出 BMP 内任意 code point。"é" 是 é。多数 JSON 编码器对非 ASCII 字符默认就是这种防御性写法——直接嵌 UTF-8 也合法,但 \uXXXX 经得起任何会破坏非 ASCII 字节的传输通道。
对于 BMP 之外的 code point(任何 U+FFFF 之上的字符——大部分 emoji、所有补充平面),JSON 没有原生转义。它借用 UTF-16 的 surrogate pair 约定:"😀"(U+1F600)写成两个 \uXXXX,合在一起编码这个补充平面 code point。对 UTF-16 原生语言(JS、Java)这没毛病;对其他语言则是个小麻烦——必须识别这一对、再合成。
Lone surrogate 陷阱
这一段是真正的乐子。
JSON 允许 \uD800 单独出现。
U+D800 是 surrogate code point——本身在 well-formed Unicode 里非法、只能作为 surrogate pair 的一半。但 RFC 8259 没有强制 well-formed Unicode。{"x": "\uD800"} 这样的文档在大多数库里都能正常解析。
问题在后面:这个字符串无法被序列化成 UTF-8。UTF-8 没有 lone surrogate 的表达方式。一次 JSON → 字符串 → UTF-8 → 字符串 → JSON 的往返,要么报错、要么悄悄替换成 U+FFFD(替换字符)——具体是哪种取决于库。
这就是最离奇的 JSON bug 来源。一个文档解析正常、被持久化、几跳之后数据已经变了。特征是文本里某个该有意义字符的地方出现了 lone U+FFFD——通常是某个 emoji 在传输中丢了一半 surrogate。
如果生产端在你手里,永远不要发 lone surrogate。如果消费端在你手里,显式决定是拒绝还是替换,不要让库的默认行为决定。
U+2028 / U+2029 怪事
直到 2019 年,JSON 都不是 JavaScript 的子集。
原因是:JSON 允许 U+2028(行分隔符)和 U+2029(段落分隔符)在字符串里不转义。JavaScript 字符串字面量不允许——这两个字符在 JS 字符串语法里非法。所以一个含有这两个字符的 JSON 文档,对 JSON.parse 是合法的,对 eval 不合法。
这事在 JSONP 时代很要命——你给浏览器发一段 JSON-like payload 包在 callback(...) 里、让浏览器当 JS 执行。JSON 文档里夹了一个 U+2028,JSON.parse 收下没问题,浏览器当 JS 跑就解析失败。生产事故由此而来。
ES2019 修了这件事,允许 U+2028 和 U+2029 出现在 JS 字符串字面量里。但旧引擎在长尾环境里还存在(嵌入式 webview、博物馆级浏览器),所以面向直接 JS 消费的 JSON 输出,仍然防御性地把这两个 code point 转义成
和
。
常见踩坑
- 手工拼 JSON。用户控制的字符串里出现
"或\就把整个文档破坏掉。这同时是 JSON 注入向量,类似 SQL 注入。用序列化器。 - 依赖跨工具的 surrogate 一致性。Python 的
json.dumps(ensure_ascii=False)输出 UTF-8 字面字符,json.loads又能愉快接受含\uD800的文档。两个工具单看都没错,组合起来不兼容。 - 忘了控制字符必须转义。
\n在 JSON 里是字面量的反斜杠 + n;真正的换行字节是解析错误。 - 把
\/当错误。它是可选合法形式。在输入里把它当"格式不规范"剥掉,会吃掉真实数据。 - 以为
JSON.parse后再序列化等于原字节。不等于,两个方向都不等于。一次往返可能改变 Unicode 规范化、surrogate 配对、key 顺序。把 JSON 当传输用、不要当忠实回声用。
实用规则
- 永远用真正的序列化器。手工实现的转义函数能正确处理的边角情况,比你想的少得多。
- 不可信字符串嵌入 HTML 的
<script>标签时:要么用 context-aware 序列化器(额外转义<、>、&、'、U+2028、U+2029、/),要么把 JSON 渲染到<script type="application/json">块里——后者直接绕开了脚本标签 breakout 这一整类攻击。 - 存储和 API:emit UTF-8 字面字符(非 ASCII 不要
\uXXXX),\uXXXX留给控制字符。结果更小、更易肉眼检查。 - 在边界处拒绝 lone surrogate。不要让它进入系统内部传播。
- 把 JSON 输出当作单向:parse 再 reserialize 不保证字节相等,哪怕语义相等。
主要参考资料
用于核对本文技术细节的标准与官方文档。
任意字符串往返检查
本站 JSON 转义工具会展示一段字符串经过 JSON 序列化后的精确转义序列(包括非 ASCII 字符的 \uXXXX 形式),并能反向解码回来。本地处理,对验证“这串数据能否安全往返”特别有用。
打开 JSON 转义工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。