尧图精选

JSON转义与HTML实体转义混淆:定位后端收到quot;与反序列化异常

🕒 发布时间:2026/10/1 9:43:52 📁 来源:尧图网络
1.quot;和\是两条完全不同的转义链路先别混为一谈前端传 JSON、后台收到quot;这个现象我前后遇到过不下五次每次的根因都不一样。刚接手的同学最容易犯的错是把\反斜杠加引号和quot;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;titlequot;: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;namequot;: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 typehidden或者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.1RequestBody接对象和接 String 的差异同样的请求用对象接和用 String 接结果天差地别// 方式一接对象Jackson 负责反序列化会自动还原 JSON 转义 PostMapping(/save) public Result? save(RequestBody Article article) { log.info(content {}, article.getContent()); return Result.ok(); } // 方式二接 Stringbody 原样进来不做任何解析 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;namequot;: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 拦截器修复方式换文本渲染方式让过滤器放行 JSON4. 一层层剥开一次完整的定位过程复盘光讲原理容易飘我拿一个自己实际处理过的案例走一遍。现象是一个后台管理系统用户在富文本框里输入带引号的内容保存后再打开引号位置显示成了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;charsetUTF-8 }, body: JSON.stringify(payload) }); console.log(返回:, await res.json()); }后端就一个接口PostMapping(/api/note) public Result? save(RequestBody MapString, String body) { String content body.get(content); log.info(后端收到 content {}, content); noteService.save(content); return Result.ok(); }4.2 按链路顺序打日志打日志要沿着链路从入口到出口一个点都别跳浏览器 Network请求体是{content:他说\你好\}前端序列化正常。后端 Controller 入口log.info打出来是他说quot;你好quot;说明进 Controller 之前就已经被转义了。过滤器链在过滤器里打印getInputStream读到的内容确认是{content:他说quot;你好quot;}。定位到 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写全带上charsetUTF-8fetch(/api/note, { method: POST, headers: { Content-Type: application/json;charsetUTF-8 }, body: JSON.stringify(payload) });不带字符集在某些服务器上会按 ISO-8859-1 解析中文直接变乱码这是个老坑顺手加上省心。5.2 后端层面让过滤器只处理该处理的类型前面给过代码了核心就是按Content-Type分流。这里补充一个细节判断的时候不要用equals全等比较因为application/json;charsetUTF-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;charsetUTF-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 过滤器想防护、模板引擎想安全、编码函数想兼容结果都不知道对方也在动手叠在一起就出了乱子。所以遇到这类问题别急着写反转义先沿着数据流从源头到终点走一遍把每一层“是否动过手”列出来往往还没走到终点源头就自己冒出来了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →