☰
JSON转义与HTML实体转义混淆:定位后端收到quot;与反序列化异常
2026/10/1 9:43:51 网站建设 项目流程

1."和\"是两条完全不同的转义链路,先别混为一谈

前端传 JSON、后台收到",这个现象我前后遇到过不下五次,每次的根因都不一样。刚接手的同学最容易犯的错,是把\"(反斜杠加引号)和"(HTML 实体)当成同一类东西,然后在上网搜出来的“万能反转义”代码里来回试,结果越修越乱。所以动手之前,得先把这两条链路分清楚:JSON 转义是规范行为,HTML 实体转义则一定是某个环节主动“动过手”。

\"的出现是合法的。你调用JSON.stringify把一个普通对象序列化成字符串,字符串值里的双引号必然会被加上反斜杠,否则解析器分不清哪个引号是结构、哪个引号是内容。这是 JSON 规范(RFC 8259)里白纸黑字写死的,后端用 Jackson、Gson、Fastjson 反序列化时会自动还原,你根本不需要做任何事。

&quot;就完全是另一回事了。它属于 HTML 实体(HTML Entity)体系,&quot;是双引号"的实体写法,&amp;是&,&lt;是<。这套东西是给 HTML 用的,作用是让浏览器在渲染时正确显示那些会跟标签语法打架的字符。JSON 解析器不认识&quot;,它只会把它当成五个普通字符。所以一旦 JSON 报文里混进了&quot;,逻辑上只有一种可能:请求体在被解析之前,被某个环节按 HTML 的规则转义了一遍。

1.1 从 Network 面板里看清发出去的到底是什么

排查第一个动作,永远是打开浏览器开发者工具的 Network 面板,找到那一条请求,看Request Payload或者请求体原文。这里有个特别容易让人误判的细节:Chrome 的网络面板在展示 JSON 时,会做一次“美化”和友好化处理,你在面板里看到的内容和实际发出去的字节流可能不完全一样。想看真实字节,得右键那一行,选择Copy as fetch或者Copy as cURL,粘到文本编辑器里看。

如果你在 Network 里看到的请求体是干干净净的{"title":"测试","content":"他说\"你好\""},那前端是清白的,问题出在后端。如果你看到的就是{&quot;title&quot;:&quot;测试&quot;...},那前端就已经被污染了,往下去查前端哪一步动了手。这一步不做,后面全是猜。

注意:有些同学习惯用console.log(JSON.stringify(obj))来“验证”发出去的内容,这个其实不可靠。控制台输出的是对象序列化的结果,跟网络层实际发送的 body 未必一致,中间还可能隔着 axios 的转换器、拦截器、请求包装。以 Network 面板的 Copy as fetch 为准。

1.2 JSON 层的\"是规范行为,不该去动它

我把 JSON 转义的常见形态列一下,方便对照,省得你看到反斜杠就紧张。

原始内容JSON.stringify 之后说明
他说"你好"他说\"你好\"内容里的引号被转义,正常
换行符\n控制字符被转义,正常
反斜杠\\\自身转义,正常
制表符\t控制字符,正常

看到这张表你就明白了:\"的源头是内容里本来就有引号,而 JSON 序列化器为了保证语法正确做了转义。后端反序列化时会自动还原成他说"你好"。如果你在后端拿到的字段值是他说\"你好\"(带着反斜杠),那说明后端是用 String 接的整个 body 又没解析,这属于另一类问题,跟&quot;不是一回事。

1.3 HTML 实体层的&quot;一定是被主动种进去的

&quot;不可能凭空出现。在 Java 世界里,能产生它的最常见调用就这几个:

// Spring 自带,最常用 org.springframework.web.util.HtmlUtils.htmlEscape("\"") // 返回 &quot; // Apache Commons Text org.apache.commons.text.StringEscapeUtils.escapeHtml4("\"") // 返回 &quot; // 老版本 commons-lang org.apache.commons.lang3.StringEscapeUtils.escapeHtml("\"") // 返回 &quot;

一旦某个环节把上面的任一个调用套在了请求体上,{"name":"张三"}就会变成{&quot;name&quot;:&quot;张三&quot;}。这时候传给 Jackson 会怎样?直接抛反序列化异常,报错信息往往就是热词里那个failed to deserialize the json body into the target type。就算你后端图省事用 String 收下整段 body,存进数据库的也是带&quot;的脏数据。

1.4 两种转义叠加之后的样子最迷惑人

最让人头大的是叠加转义。内容里本来有个引号,前端序列化成\",中间某层又做一次 HTML 转义,\没被处理但"变成了&quot;,于是你看到\&quot;。再叠加一层,&又变成&amp;,就成了&amp;quot;。这种“多层转义”在富文本、老后台系统里非常常见,你手动反转义一次根本不够,得转义几次反几次。

判断当前是哪一层,有个土办法:看&的情况。如果只有&quot;,说明只做了一次 HTML 转义;如果出现&amp;quot;,说明做了两次;如果连&都被处理成了&amp;而引号还是原始引号,那说明转义发生在 JSON 序列化之外。这个小技巧能帮你快速缩小范围。

2. 前端这边:从对象到请求体,每一步都可能悄悄加料

确认前端有嫌疑之后,别急着改代码,先沿着数据流动的路径走一遍,因为从你手里的 JS 对象到最终发出的字节流,中间至少有三到四个地方可能被“加料”。很多人一上来就怀疑JSON.stringify,其实它是最无辜的那个,真正捣乱的是手工拼接、重复编码和某些组件的默认行为。

2.1 先用 Copy as fetch 把真实报文钉死

前面说过了,这一步是基准。拿到真实的 fetch 代码后,重点看两处:一是headers里的Content-Type,二是body的确切内容。如果Content-Type写的是application/json而 body 里出现&quot;,那前端就有问题。如果Content-Type写的是application/x-www-form-urlencoded或者根本没写,浏览器可能按表单格式处理,那出来的东西就不一定是 JSON 了。

还有一种隐蔽情况:请求被 axios 的transformRequest改过。默认情况下 axios 会对application/json的对象做JSON.stringify,但如果你自己覆写了transformRequest,或者项目里装了某个请求库的插件,实际发出的内容可能会被二次处理。定位方法很简单,在transformRequest里打一行日志,把处理前后的字符串都打出来对比。

2.2 JSON.stringify 和 encodeURIComponent 叠加调用

这是新手最容易掉的坑,没有之一。典型的错误写法长这样:

// 错误示范:先序列化,再对整个 JSON 字符串做 URL 编码 const payload = JSON.stringify({ content: '他说:"你好"' }); const encoded = encodeURIComponent(payload); fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: encoded // 传进去的已经不是 JSON 了 });

encodeURIComponent会把"、{、}、:全变成%XX形式,或者在某些实现里把部分字符转成实体。后端按application/json解析,自然全军覆没。JSON 请求体不需要做 URL 编码,URL 编码是给 query string 和表单准备的。如果你确实要在 URL 里传 JSON,那才需要encodeURIComponent,而且后端要对应地decode。

另一个变种是escape(),这个函数早已废弃,而且它会把"转成%22、把非 ASCII 字符转成%uXXXX,跟 UTF-8 完全对不上。看到项目里有escape处理 JSON 的,直接改掉。

2.3 表单提交与 URLSearchParams 的隐式转义

如果接口不是用 fetch 发的 JSON,而是传统表单或者URLSearchParams,那情况又不一样。URLSearchParams会对值做 URL 编码,后端如果用@RequestParam接收,Spring 会自动decode,一般不出问题。但如果后端把整个表单值当字符串取出来当 JSON 解析,而前端传的值里恰好有",那就可能出现转义不一致。

还有一种情况是前端把 JSON 塞进<input type="hidden">或者<textarea>里随表单提交。HTML 表单在提交时会对值做 HTML 实体编码吗?不会,但如果你是通过innerHTML设置的隐藏域内容,浏览器在解析 HTML 时会把&quot;还原成",这个反而是对的。真正会出问题的是反向操作:用innerHTML读取一个包含引号的值,或者用某些模板引擎(如 Thymeleaf、JSP 的c:out)输出到页面,这些地方默认会做 HTML 转义。

2.4 富文本框和组件库的自主转义

富文本编辑器是个重灾区。像某些基于 contenteditable 的编辑器,内部会把内容序列化成 HTML 再存起来,为了让 HTML 合法,引号属性会被转成&quot;。如果这个 HTML 字符串被当成 JSON 的一个字段值发送,里面自然就带着&quot;了。这种情况下&quot;其实是内容的合法组成部分,不该被反转义,反转义了反而破坏结构。

判断方法是看你的业务语义:如果字段值本来就是一段 HTML,那&quot;属于内容,要在渲染层处理;如果字段值应该是纯文本,那&quot;就是被误伤的,需要在入库前还原。分不清这一点,就会陷入“改了这边坏那边”的循环。

3. 后端这边:参数绑定、过滤器和模板三层里谁动了手

前端确认清白之后,矛头就指向后端。后端这条链路上能改动请求体的地方比前端更多,而且很多是框架层面的“默认行为”,不看源码根本发现不了。我把它拆成三层:参数绑定层、过滤器层、输出层,一层层往下排。

3.1@RequestBody接对象和接 String 的差异

同样的请求,用对象接和用 String 接,结果天差地别:

// 方式一:接对象,Jackson 负责反序列化,会自动还原 JSON 转义 @PostMapping("/save") public Result<?> save(@RequestBody Article article) { log.info("content = {}", article.getContent()); return Result.ok(); } // 方式二:接 String,body 原样进来,不做任何解析 @PostMapping("/saveRaw") public Result<?> saveRaw(@RequestBody String body) { log.info("raw body = {}", body); return Result.ok(); }

方式一拿到的是还原后的内容,方式二拿到的是包含\"的原始 JSON 文本。如果你在方式二里直接把 body 存库,那库里存的就是带反斜杠的 JSON,前端再取出来展示时,反斜杠就会显示出来,用户看到的就是他说\"你好\"。这不是&quot;,但道理相通:用 String 接 JSON body,等于放弃了框架帮你做的反序列化。

一个常见的错误修复方式是,在方式二里再手动JSON.parse(body)一次,结果因为 body 里已经被转义过,parse 出来还是不对。正确做法是用对象接,或者明确知道自己在做什么才用 String。

3.2 全局 XSS 过滤器对 JSON body 的误伤

这是&quot;最可能的来源。网上流传的很多 XSS 过滤器实现,思路是“包装HttpServletRequest,把所有参数里的危险字符转义掉”。问题出在,很多实现把getInputStream也一并包装了,对请求体全文做HtmlUtils.htmlEscape。这种实现在表单场景下没问题,但一旦请求是 JSON,就会把 JSON 的结构引号也一起转义。

// 有问题的过滤器写法:不管内容类型,全文转义 public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { @Override public ServletInputStream getInputStream() throws IOException { String body = readBody(super.getInputStream()); String escaped = HtmlUtils.htmlEscape(body); // 灾难从这里开始 return new WrappedServletInputStream(escaped.getBytes(StandardCharsets.UTF_8)); } }

{"name":"张三"}经过这一手,变成{&quot;name&quot;:&quot;张三&quot;},Jackson 直接抛异常。正确的做法是让过滤器根据Content-Type判断:

public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { @Override public ServletInputStream getInputStream() throws IOException { String contentType = getContentType(); // JSON 请求体交给 Jackson 自己处理,过滤器不插手 if (contentType != null && contentType.contains(MediaType.APPLICATION_JSON_VALUE)) { return super.getInputStream(); } String body = readBody(super.getInputStream()); String escaped = HtmlUtils.htmlEscape(body); return new WrappedServletInputStream(escaped.getBytes(StandardCharsets.UTF_8)); } }

如果确实需要对 JSON 内容做 XSS 防护,正确姿势是用 Jackson 反序列化成对象,递归地对每个 String 字段做转义,再重新序列化。这样只会转义字段值,不会碰结构。不过这属于比较重的实现,一般项目里让前端在渲染时转义就够了,后端不必画蛇添足。

3.3 模板引擎与日志输出阶段的转义

如果你发现数据库里存的是好的,但页面显示或接口返回时变成了&quot;,那转义发生在输出阶段。典型的是 JSP 的<c:out>、Thymeleaf 的th:text,它们默认对变量做 HTML 转义。这本身是安全设计,防止 XSS,但它只该用于 HTML 输出,不该用于接口返回的 JSON。

还有一种更隐蔽的:日志框架或 AOP 切面在打印请求参数时做了转义,把转义后的内容又回写到了某个地方。这种 bug 极其难查,因为日志里看到的是转义后的内容,但你不知道是日志打印时转的,还是数据本来就脏。

3.4 数据库落库时的二次转义

有些老项目在 DAO 层或者 ORM 拦截器里加了“防注入”处理,对字符串做了 HTML 转义再入库。如果这个拦截器跟 XSS 过滤器叠加,就会出现双重转义&amp;quot;。定位方法是直接连数据库看原始值,如果库里就是&quot;,说明问题在入库前;如果库里是好的,说明问题在读取或输出时。

观察点库里是好的库里是&quot;
问题位置输出/渲染阶段写入阶段
排查方向模板引擎、接口返回序列化过滤器、DAO 拦截器
修复方式换文本渲染方式让过滤器放行 JSON

4. 一层层剥开:一次完整的定位过程复盘

光讲原理容易飘,我拿一个自己实际处理过的案例走一遍。现象是:一个后台管理系统,用户在富文本框里输入带引号的内容,保存后再打开,引号位置显示成了&quot;。这个 case 的价值在于,它同时涉及前端、后端和数据库三个层面。

4.1 最小复现 Demo 的搭建

先做一个最小复现,把变量降到最少。前端就一个页面,一个输入框,一个按钮,点一下发请求:

async function save() { const content = document.getElementById('content').value; const payload = { content }; // 假设输入是:他说:"你好" console.log('发出的 body:', JSON.stringify(payload)); const res = await fetch('/api/note', { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify(payload) }); console.log('返回:', await res.json()); }

后端就一个接口:

@PostMapping("/api/note") public Result<?> save(@RequestBody Map<String, String> body) { String content = body.get("content"); log.info("后端收到 content = {}", content); noteService.save(content); return Result.ok(); }

4.2 按链路顺序打日志

打日志要沿着链路,从入口到出口,一个点都别跳:

  1. 浏览器 Network:请求体是{"content":"他说\"你好\""},前端序列化正常。
  2. 后端 Controller 入口:log.info打出来是他说&quot;你好&quot;,说明进 Controller 之前就已经被转义了。
  3. 过滤器链:在过滤器里打印getInputStream读到的内容,确认是{"content":"他说&quot;你好&quot;"}。
  4. 定位到 XSS 过滤器:代码里确实有一个对getInputStream全文HtmlUtils.htmlEscape的实现,且没有区分Content-Type。

到这一步,根因就钉死了:XSS 过滤器把 JSON body 的结构和内容一起转了。

4.3 从现象反推转义发生的层

反过来,如果你手上只有一个现象,没有日志,也可以按下面的顺序推断:

  • 现象是&quot;出现在数据库里 → 写入之前就被转义,重点查过滤器和 DAO 拦截器。
  • 现象是数据库干净、接口返回带&quot;→ 输出阶段被转义,查模板引擎和序列化配置。
  • 现象是后端接口返回干净、前端页面显示&quot;→ 前端渲染方式的问题,见第 6 节。
  • 现象是&amp;quot;→ 至少经历了两次转义,大概率是过滤器叠加 DAO 拦截器。

这套推断不需要你能看到源码,靠现象和几个观察点就能把范围缩到一两处,效率很高。

5. 修复方案与适用边界对比

定位清楚之后,修复本身不难,难的是选对层次。改错地方往往按下葫芦浮起瓢,所以我把常见方案列出来,标清楚各自的适用场景和副作用,你对号入座。

5.1 前端层面:去掉多余的编码

如果前端存在encodeURIComponent、escape、手工拼接等操作,直接删掉,让JSON.stringify独立完成序列化。同时把Content-Type写全,带上charset=UTF-8:

fetch('/api/note', { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify(payload) });

不带字符集在某些服务器上会按 ISO-8859-1 解析,中文直接变乱码,这是个老坑,顺手加上省心。

5.2 后端层面:让过滤器只处理该处理的类型

前面给过代码了,核心就是按Content-Type分流。这里补充一个细节:判断的时候不要用equals全等比较,因为application/json;charset=UTF-8和application/json是不同的字符串,用contains更稳。另外,如果你的过滤器还处理 query string,那部分照旧处理,不受影响。

5.3 存量数据的清洗

已经被污染的数据,得单独清洗。原则是:只清洗那些确定是纯文本的字段,HTML 字段不动。

-- 先统计影响范围,别上来就改 SELECT COUNT(*) FROM note WHERE content LIKE '%&quot;%'; -- 确认无误后再更新,替换前后都留一份备份 UPDATE note SET content = REPLACE(content, '&quot;', '"') WHERE content LIKE '%&quot;%';

如果存在双重转义&amp;quot;,要分两次替换,且顺序不能反,得先把&amp;quot;换成&quot;,再把&quot;换成",否则一次替换会得到错误结果。这种操作务必在测试库上先跑一遍验证。

5.4 各方案对比

方案改动位置适用场景风险
删除前端多余编码前端前端有二次编码低,注意别删掉必要的 URL 编码
过滤器按类型放行后端XSS 过滤器误伤 JSON低,但要注意保留表单防护
接入 JSON-aware 转义后端确实需要对 JSON 值做 XSS 防护中,实现复杂,容易漏字段
存量数据 SQL 清洗数据库历史脏数据高,务必先备份再执行
输出层换渲染方式前端页面显示问题低,但可能影响其他展示逻辑

6. 往后不再踩:接口契约与渲染的几条硬规矩

修完一次不算完,关键是把规矩定下来,让同一个坑不再出现第二次。下面这几条是我在几个项目里沉淀下来的,比较实在。

6.1 Content-Type 和字符集必须在契约里写死

前端发 JSON 就写application/json;charset=UTF-8,后端接 JSON 就用@RequestBody接对象,双方别在格式上留模糊地带。接口文档里把请求体的示例报文贴全,含引号、含换行、含中文的都要有。很多转义问题不是因为代码写错,而是因为双方对“这个字段到底是不是 JSON”理解不一致。

6.2 转义只在输出到 HTML 的那一刻做

这是个原则问题:转义要发生在最靠近渲染的那一层。数据在传输、存储、处理过程中都应该是原始的,只有最终要拼进 HTML 时才做实体转义。很多项目反着来,在入口就转义,结果数据在链条里被转来转去,到最后谁也说不清原始内容是什么。入口保持干净,出口按需转义,这是最省心的架构。

6.3 前端的渲染用文本节点而不是 innerHTML

如果你的页面显示&quot;,很多时候是因为用错了渲染方式。用innerHTML插入内容时,浏览器会把字符串当 HTML 解析,实体字符会被当作标记处理;用textContent或者 Vue/React 的文本插值{{ }},则会按纯文本处理,&quot;会原样显示。既然&quot;是脏数据,那就应该在数据层解决,而不是靠渲染层“恰好”把它显示成引号——那是掩盖问题,不是解决问题。

// 不推荐:把带实体的脏数据塞给 innerHTML el.innerHTML = dirtyContent; // 推荐:用文本节点插入,同时在数据层保证 content 是干净的 el.textContent = cleanContent;

最后说个我自己的体会。这类转义问题,十有八九是“某个中间层想做好事”惹出来的——XSS 过滤器想防护、模板引擎想安全、编码函数想兼容,结果都不知道对方也在动手,叠在一起就出了乱子。所以遇到这类问题,别急着写反转义,先沿着数据流从源头到终点走一遍,把每一层“是否动过手”列出来,往往还没走到终点,源头就自己冒出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询