ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端传 JSON 后台被转 quot;:HTML 实体转义与接口解析失败排查

前端传 JSON 后台被转 quot;:HTML 实体转义与接口解析失败排查 上周三下午四点测试同学甩过来一张截图我盯着看了半分钟才反应过来发生了什么。前端明明提交的是一段规规矩矩的商品 JSON{skuId:8812,name:磨砂玻璃杯,qty:2}落到后台日志里却长成了这副模样——{quot;skuIdquot;:quot;8812quot;,quot;namequot;:quot;磨砂玻璃杯quot;,quot;qtyquot;:2}。整段字符串像被撒了一层芝麻每个双引号都变成了quot;后端解析直接失败接口报 400前端页面卡在转圈状态。这个问题的关键词就是前端传 json后台接收被转为 。它不是什么高深的算法难题却是那种一旦撞上就让人抓头发的连接层脏活。你会看到quot;这种 HTML 实体编码混进了本该是纯 JSON 的载荷里JSON 解析器当然不认Jackson 会甩出failed to deserialize the json body into the target type: input: missing field这类看着毫不相关的错误让人误以为是字段名写错了。这篇文章我打算把这条链路从头到尾拆开讲清楚quot;到底是谁偷偷加进去的浏览器、模板引擎、网关、后端过滤器各自扮演什么角色五道关口怎么一层层排查四类典型场景分别怎么修最后再给一份我自己整理的速查表和踩坑记录。不管你写的是 Vue、React 还是原生 js后端是 Spring Boot、Express 还是 Python 的某个框架只要你的接口出现过这种引号变形的现象都能在这里找到对应的定位思路。适合刚入行的前端同学建立链路意识也适合做了几年但没系统梳理过编码问题的开发者查漏补缺。1. 现象还原一段 JSON 在后台变成了 密集恐怖片在动手改代码之前我一直强调一件事先把现象描述到开方的程度。很多人一看到乱码就下意识怀疑是不是前端没转义或者是不是后端编码没设置 UTF-8然后一顿瞎改最后问题还在。我们得先弄清楚这次的现象和中文乱码根本不是同一回事它是字符级的实体替换而不是字节级的编码错误。1.1 一个真实的报障现场先把现场还原得更完整一点。前端是一个商品下单页面用户选好数量点提交前端把整个表单拼成一个对象JSON.stringify之后发出去Content-Type设的是application/json。代码大概长这样const payload { skuId: 8812, name: 磨砂玻璃杯, qty: 2 }; fetch(/api/order/create, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) });这段代码本身挑不出毛病本地用 Postman 直接打同样的 JSON后端一切正常。可一旦从页面上点提交后端收到的请求体就变成了带quot;的版本Jackson 反序列化立刻失败接口返回HttpMessageNotReadableException日志里那句failed to deserialize the json body into the target type: input: missing field就是它的典型马甲。我当时的第一个疑问不是谁改的而是改的范围有多大。于是我做了三件事第一打开浏览器 Network 面板抓这个请求的原始载荷第二把请求头完整复制下来看 Content-Type 有没有被中间层改过第三让后端同事把request.getInputStream()读到的原始字节打出来。三份数据一对比问题就现形了——浏览器发出的字节里就已经带quot;了也就是说污染发生在前端或前端到服务端之间的某一环跟后端的 JSON 解析器无关。1.2 先别急着改代码回答我三个问题我给自己列了一张定位问卷每次遇到这类传输问题都会照着问一遍问完基本能锁定范围。你也可以把它当成本文的总纲第一问污染发生在哪一段是浏览器发出的字节就脏还是到了网关才脏还是进了后端框架才脏这决定了排查方向是前端、运维还是后端。第二问quot;是唯一症状吗如果、、也被替换成了lt;、gt;、amp;那就是典型的HTML 实体转义如果只有引号变形可能是某个特定组件干的。第三问换一个客户端能复现吗用 Postman、curl 直接打接口如果都正常说明问题只存在于从页面走这条路径上范围立刻收窄。我当时对着这三问答完结论就很明确了污染发生在页面提交的那一刻而且是 HTML 实体转义因为我在抓包里同时看到了quot;和少量amp;。这就把问题从玄学乱码变成了谁做了 HTML 转义这种可以精确打击的问题。1.3 为什么这个问题特别容易误诊我特别想强调一点quot;问题之所以高频是因为它伪装得太像别的问题。有人看到 JSON 解析失败第一反应是字段名对不上于是去改 DTO 的注解有人看到引号变形第一反应是编码没设 UTF-8于是去改 tomcat 配置还有人觉得是JSON 格式化工具的锅换个在线工具再复制一遍结果毫无变化。真相是quot;是这个字符在 HTML 世界里的名字。JSON 语法里的双引号在 HTML 语法里是特殊字符任何一次把字符串当 HTML 来解析或序列化的动作都可能把它替换成实体形式。所以定位的方向不是编码而是要找到链路上第一次把这段字符串当 HTML 处理的那个环节。找对了环节改动量往往只有几行。2. 把概念捋直 不是乱码是 HTML 实体很多开发者对转义这个词是模糊的以为所有开头的东西都是加密或者编码错误。我见过工作三年的同事把quot;和 URL 里的%22混为一谈也见过有人把 HTML 实体当成 Base64 的一种。概念不清排查就全靠猜。这一节我把最关键的三组概念掰开把quot;的出身说明白。2.1 HTML 实体转义的五种字符与触发条件HTML 实体HTML Entity是一套字符引用机制用来在 HTML 文档里表示那些具有特殊语法含义的字符。跟写代码最相关的有五个原始字符实体编码在 HTML 里的含义不转义的后果amp;实体引用的起始符后半段被误认成实体解析错乱lt;标签开始被当成新标签DOM 结构被破坏gt;标签结束同样破坏结构quot;属性值的界定符属性被提前截断#39;属性值界定符同样截断属性触发它的场景有个共同的本质这段字符串被当作 HTML 源码来解析或重新序列化。典型的触发动作包括读写innerHTML、读取outerHTML、服务端模板引擎输出变量、富文本编辑器处理内容、以及一些安全组件对输入做消毒。反过来以下操作绝对不该产生quot;JSON.parse、JSON.stringify、encodeURIComponent、decodeURIComponent、Base64 编解码——它们各有各的转义规则但都不会产出 HTML 实体。理解了这一点你就能一眼分清quot;HTML 实体和%22URL 百分号编码的区别前者是给 HTML 解析器看的后者是给 URL 解析器看的。如果后端日志里看到的是%22那是 URL 编码问题方向完全不同。这就是为什么我反复强调先分清症状再动手。2.2 三种编码别再混为一谈我给团队新人培训时一定会放这张对比表因为这是最容易搞混的部分编码类型代表形式服务对象典型 API使用场景URL 编码%22%E5%8C%85URL / 表单encodeURIComponentquery 参数、form 表单值HTML 实体quot;amp;HTML 解析器escapeHtml/ 实体替换页面渲染、属性注入Base64Ig任意二进制转文本btoa/base64图片、token、附件这三者互不替代。用encodeURIComponent处理一段要放进 HTML 属性的字符串防不住 HTML 注入用 HTML 实体转义一段要放进 URL 参数的字符串到了后端getParameter也不会自动还原。quot;问题本质上就是有人在错误的场景用了错误的转义或者在不该转义的地方多做了一次 HTML 转义。我遇到过最典型的误用是用escape()处理 JSON。escape是早年 js 里一个已经废弃的函数它会把转成%22把中文转成%uXXXX行为诡异且和encodeURIComponent不一致。一旦有人用了它后端拿到的就是一堆%u开头的东西跟quot;又是另一种灾情。所以我的建议很直接别再碰escape要 URL 编码就用encodeURIComponent。2.3 为什么浏览器和模板引擎非要转义搞明白为什么要转义才能理解这不是 bug 而是设计。想象一下这个情形你把用户昵称直接拼进 HTML昵称是scriptalert(1)/script浏览器会把它当成一个脚本标签执行——这就是经典的 XSS 攻击。为了防止这种事所有正经的模板引擎和 DOM 操作都会在输出到 HTML 上下文时自动转义特殊字符。这是保护不是故障。问题就在于JSON 数据往往不是用来渲染 HTML 的而是用来做数据传输的。当一段 JSON 被误放进 HTML 上下文里走了一圈再取出来它就被转义了然后带着quot;发给了后端。所以正确的思路不是关掉转义而是让 JSON 走它该走的通道别让它路过 HTML。这个原则会贯穿后面所有的修复方案。3. 逐层排查从浏览器到数据库的五道关口知道quot;是 HTML 实体之后剩下的工作就是找到链路上第一个做转义的环节。我习惯把一次请求拆成五道关口从上往下过一遍基本不会漏。下面按请求的流动顺序讲你可以照着顺序查。3.1 关口一Network 面板看原始载荷第一关永远是浏览器。打开 DevTools 的 Network 面板找到那个失败请求切到 Payload 或 Request 标签看Request Payload 的原文。这里要注意一个坑Chrome 会对 JSON 请求体做美化显示有时候还会把长字符串折叠你看到的不是原始字节。稳妥的做法是右键请求选择 Copy as cURL把命令粘到文本编辑器里看--data-raw后面的内容那才是真正发出去的字节。判断标准很简单如果--data-raw里就是{quot;skuIdquot;...}污染在前端继续看第 3.2 节。如果--data-raw里是干净的{skuId:...}但后端收到的是脏的污染在中间层或后端跳到 3.4 和 3.5。这一步是整个排查的分水岭可偏偏很多人跳过它直接改代码。我曾经见过一个同事花了两小时改后端过滤器结果问题根本在前端的一个innerHTML读取上。所以先把原始载荷看清楚再谈改哪里。3.2 关口二前端取值姿势自查清单如果第一关确认污染在前端那就往下查前端的代码。下面这份清单是我自己总结的命中率很高是否用innerHTML或outerHTML读取过包含这段数据的内容读取innerHTML会把属性值里的引号实体化。是否用字符串拼接的方式往 HTML 里塞过 JSON比如el.innerHTML div>// 错误从 innerHTML 里读 JSON引号会被实体化 const raw document.getElementById(jsonBox).innerHTML; const data JSON.parse(raw); // 这里会抛 SyntaxError正确写法分几种情况// 情况一数据存在隐藏表单域里用 value 读 const raw document.getElementById(jsonBox).value; // 情况二数据存在>// 拼接前先对 JSON 做属性值转义 function escapeAttr(str) { return str.replace(//g, amp;) .replace(//g, quot;) .replace(//g, lt;) .replace(//g, gt;); } // 或者更推荐根本不用字符串拼 DOM const box document.createElement(div); box.dataset.payload JSON.stringify(obj); container.appendChild(box);第二种写法用dataset赋值浏览器会自己处理转义你读的时候box.dataset.payload拿到的就是原始 JSON完全绕开了实体化。能用 DOM API 就别用字符串拼 HTML这是我这些年最深的体会。注意.html()拿到的是源码视角.text()拿到的是文本视角。凡是拿数据做业务逻辑的一律用文本视角的方法。4.2 场景二form-urlencoded 里塞 JSON有一种历史遗留写法是把 JSON 当作表单的一个字段提交请求长这样POST /api/order/create Content-Type: application/x-www-form-urlencoded data{skuId:8812,qty:2}这种写法本身没错但前提是值里的特殊字符要经过URL 编码。前端处理时要这样const jsonStr JSON.stringify(payload); const body data encodeURIComponent(jsonStr); // encodeURIComponent 会把 变成 %22把 变成 %26把 变成 %3D fetch(/api/order/create, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: body });如果用 jQuery 的$.ajax它默认会帮你做 URL 编码但如果你手动拼字符串就一定要自己编码。后端收到后getParameter(data)通常会自动做 URL 解码还原成原始 JSON然后JSON.parse即可。关键区别在于URL 编码产出%22HTML 实体产出quot;。如果你在 form 请求里看到的是quot;那说明前端在encodeURIComponent之前先对 JSON 做了一次 HTML 转义或者这个值是从某个innerHTML里读出来再拼进去的。这时候往上追一步问题大概率落在 4.1 节说的取值姿势上。提示用 form 传 JSON 是一种妥协方案。如果条件允许直接改成application/json提交请求体问题会少一大半。4.3 场景三安全过滤器把 JSON 当成了 XSS 载荷如果排查确认污染在后端或网关的过滤环节修复方案通常是给过滤器加按 Content-Type 分流的判断。下面是一个常见的 Servlet 过滤器包装器示例关键改动就在注释处public class XssRequestWrapper extends HttpServletRequestWrapper { public XssRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); if (value null) return null; // 关键判断请求类型JSON 请求体不做 HTML 转义 String contentType getContentType(); if (contentType ! null contentType.toLowerCase().contains(application/json)) { return value; } return HtmlUtils.htmlEscape(value); } }如果过滤器读的是请求体流getInputStream思路类似判断 Content-Type遇到 JSON 直接原样放行不要调htmlEscape。这样改的好处是兼顾了安全与功能页面表单提交的字段仍然有 XSS 防护纯数据接口不再被误伤。还有一种更彻底的做法在应用层声明信任边界。接口如果只对内网调用、或者有 token 校验可以考虑在该接口上跳过全局 XSS 过滤但一定要做好后续的输入校验和审计。安全措施是为了防攻击不该为了防攻击把正常业务卡死。这两者之间的平衡点就是按数据类型区分处理。4.4 场景四历史数据兜底清洗有些时候数据已经在库里存成了带quot;的形式接口也早就上线运行这时候不能只改入参逻辑还得处理存量数据。这种情况我一般写一个清洗函数在做反序列化之前先做一次去实体还原// 前端兜底把 HTML 实体还原成普通字符 function unescapeHtml(str) { const entityMap { amp;: , lt;: , gt;: , quot;: , #39;: , #x27;: }; return str.replace(/(amp|lt|gt|quot|#39|#x27);/g, function (m) { return entityMap[m] || m; }); } // 使用 const raw unescapeHtml(rawFromServer); const data JSON.parse(raw);后端也有对应实现。Java 里可以用StringEscapeUtils.unescapeHtml4commons-lang3或org.springframework.web.util.HtmlUtils.htmlUnescape。但我必须提醒一句实体还原是兜底不是主方案。它有两个明显风险。第一如果业务字符串里本来就合法地包含quot;这种文本比如一篇讲 HTML 的文章会被误还原。第二反复还原可能掩盖真正的链路 bug导致同一个位置反复出问题。正确姿势是先用兜底让线上服务恢复然后花时间找到源头并修复最后把兜底逻辑标记为临时方案安排下线。我在真实项目里就吃过亏——一个兜底函数留在代码里两年后来新同事在它基础上又叠了一层最后谁都说不清数据到底被转义了几次。注意unescapeHtml这种全局替换在处理用户生成内容时要格外小心别把正常内容里的amp;也还原了反而引起另一批数据问题。5. 常见问题速查表与踩坑心得讲完了原理和方案最后这一节留给实战中的细碎经验。这些东西我很少在文档里看到都是自己撞出来的希望你能少走一点弯路。5.1 症状对照速查表下面这张表是我贴在工位旁边的遇到传输类问题先查一遍你看到的现象最可能的原因优先排查位置JSON 里变成quot;HTML 实体转义前端取值姿势 / 安全过滤器URL 参数里变成%22URL 编码正常一般是期望行为无需处理中文变成%E4%B8%ADURL 编码检查前后端编解码是否对称中文变成#20013;HTML 实体数字编码模板引擎或过滤器的 HTML 转义failed to deserialize ... missing field请求体被污染导致解析失败抓原始载荷看引号形态变成lt;且返回页面显示正常模板引擎的默认转义属于保护按场景决定是否还原手动测试正常、页面提交异常前端代码路径差异对比两条路径的请求详情这张表不能保证 100% 准确但能帮你把完全不知道从哪下手变成有三个候选方向效率提升是数量级的。5.2 我踩过的三个坑坑一只看 DevTools 的美化视图没看原始字节。Chrome 的 Request Payload 面板对 JSON 会做折叠展示有次我看到的引号是正常的实际发出去的却带实体。后来学会了必须 Copy as cURL 看--data-raw这个习惯帮我省了太多次误判。坑二以为.html()和.val()效果一样。这两个方法在简单场景下看着没差但只要内容里含引号或者.html()就会把实体化后的版本还给你。我在一个隐藏域上栽过一次前端读出来是quot;开头的字符串JSON.parse直接炸当时还在怀疑后端纯属浪费。坑三后端过滤器一视同仁转义所有参数。有段时间线上总是偶发 JSON 解析失败排查了很久最后发现是某个统一 XSS 过滤器对application/json请求也做了实体转义只是触发条件跟参数长度有关超长才进转义分支所以偶发。这种偶发最磨人按 Content-Type 分流才是根治办法。提醒偶发问题往往说明某个分支条件不对不要用多试几次就好来糊弄一定要找到那个条件。5.3 给团队的几条硬规矩踩了这些坑之后我给团队定了几条传参规矩执行一段时间这类问题几乎绝迹传 JSON 数据统一用application/json请求体不走 form 字段不走 URL 参数减少一层编码机会。前端拼接 HTML 一律禁用需要动态渲染走 DOM API 或模板语法框架会自动处理转义。从 DOM 读取数据只用.val()、.textContent、dataset代码评审时重点看这几个方法有没有被换成.html()。全局 XSS 过滤器必须按 Content-Type 分流JSON 请求体不做 HTML 实体转义。传输层问题优先抓原始字节别信任何美化视图。兜底还原函数必须写注释标明临时和计划下线时间防止它变成永久代码。这些规矩听起来琐碎但每一条背后都是一次真实的线上事故。传输层的问题往往不复杂难的是在众多可能性里快速找到那一处。把链路意识建立起来把常见坑填上剩下的就是熟能生巧。我个人在实际操作中的体会是遇到这类问题先别急着写代码花两分钟抓个包、打行日志定位清楚了再动手比盲改快十倍。最后再分享一个小技巧如果你不确定某段字符串里有没有被实体化直接在浏览器控制台敲一句decodeURIComponent(escape(str))是没用的正确做法是看它有没有开头的实体片段有的话用本节那个unescapeHtml打印一下对比问题瞬间现形。
返回列表