
1. 先还原现场这个 到底是谁动的手前端 JS 把对象JSON.stringify之后丢给后台后台拿到的字符串里所有双引号都变成了quot;JSON.parse直接抛异常RequestBody映射对象时字段全是 null——这个场景我前后遇到过四五次每次的元凶都不一样。它的诡异之处在于你在 Chrome 的 Network 面板里点开 Request Payload看到的明明是一段干干净净的 JSON引号好好的可后端日志一打引号全成了 HTML 实体。于是前端说我发的是对的后端说我收到的是错的两边互相甩锅联调卡一整天。这篇文章就是把这个问题的完整排查路径摊开讲。它适合三类人一是正在被这个报错卡住的前端同学你需要知道自己的请求在哪一层被改写二是写了那个 XSS 过滤器的后端同学你需要知道为什么统一转义是个危险的默认策略三是正在设计前后端接口规范的人你想从源头避免这类扯皮。核心关键词会反复出现js、json、前端、后台接收、这几个词基本就是这条链路上的四个坐标点。先说清楚概念边界很多人一开始就混淆了三种引号变形导致排查方向跑偏。它们长得像产生原因完全不同变形结果编码类型典型产生位置还原方式%22URL 百分号编码encodeURIComponent、表单提交、Nginx 日志decodeURIComponentquot;/#34;HTML 实体编码XSS 过滤器、模板引擎、HTML 属性拼接HTML 反转义\JSON 字符串转义JSON.stringify正常产物JSON 解析自动处理quot;属于第二类是 HTML 实体。它的出现意味着有一方把这段 JSON 当成了要展示在 HTML 页面上的文本来处理于是做了 HTML 安全转义。理解这一点排查范围立刻就缩小了——能产生quot;的组件只有那么几类。1.1 最小复现把问题钉死在一个确定场景里排查这类问题最忌讳的就是在庞大的业务代码里瞎试。我习惯先搭一个 20 行的最小复现把变量隔离开。具体做法是新建一个最简接口接收RequestBody MapString, Object或者干脆RequestBody String原样回显前端用一个最朴素的fetch发一段固定 JSON。如果最小复现里quot;依然出现说明问题在通用链路过滤器、网关、容器如果最小复现正常说明问题在你业务代码的某个局部。最小复现的请求长这样fetch(/api/echo, { method: POST, headers: { Content-Type: application/json;charsetUTF-8 }, body: JSON.stringify({ name: 张三, remark: 备注里有 引号 }) }).then(r r.text()).then(console.log);后端这样写PostMapping(value /echo, consumes MediaType.APPLICATION_JSON_VALUE) public String echo(RequestBody String rawBody) { log.info(收到的原始 body {}, rawBody); return rawBody; }关键就在于这个RequestBody String。它绕过 Jackson 的反序列化把最原始的字节流按字符集解码后直接给你你能看到被污染前和被污染后的分界点到底在哪里。如果用RequestBody Map接收反序列化失败会直接报 400你连原始字符串都看不到排查难度翻倍。这是我踩过坑之后固定下来的第一步。顺手再教你一个量化判断的小技巧数长度。是 1 个字符quot;是 6 个字符。如果一段 JSON 里有 8 个双引号转义一次后长度至少增加8 × 5 40个字符。你在后端打印 body 长度跟前端body.length一对比膨胀比立刻告诉你转义了几层。要是看到amp;quot;那是转义了两层长度再翻一倍多——这种双重转义基本可以断定有两条链路都在做 HTML escape。1.2 三类嫌疑对象前端、网关、后端过滤器把链路从右到左捋一遍能在请求体上留下quot;的地方其实就三个第一类在前端。前端有时候自己就把 JSON 转义了。最典型的是把 JSON 塞进 HTML 属性或隐藏域里比如input typehidden value...或者textarea为了让 HTML 结构合法必须把引号写成quot;然后在取值时忘了反转义直接把outerHTML或者字符串拼出来的内容当请求体发了出去。还有一种是用了某些老旧的表单序列化库或者自己在JSON.stringify之后再手动做了一遍escape。第二类在中间层。网关、Nginx、云上的防护组件有些会带防 XSS 的请求体改写规则。这类规则为了安全会把请求体里所有都做实体编码根本不管你提交的是不是 JSON。这类问题最恶心因为你在应用日志里看不到任何痕迹只能靠抓包或者网关日志确认。第三类在后端应用内。这是最常见的。很多团队早期为了防 XSS写了一个全局 Filter对所有入参做 HTML 转义然后塞进HttpServletRequestWrapper里。这个 Filter 在写的时候只考虑了表单提交getParameter后来项目改成了前后端分离请求体变成 JSONFilter 顺手把getInputStream读出来的 body 也 escape 了一遍于是就有了这个经典 bug。把这三类记住后面每一节就是在教你如何逐一排除。2. 前端侧自查大半的锅其实在这里每次有人问我这个问题我都会先让他把浏览器的开发者工具打开切到 Network找到那个请求点开 Payload 标签页切成源代码/原始视图。注意一定要看原始视图不要看格式化之后的视图某些工具会对展示内容做美化反而掩盖了真相。如果原始视图里的引号是正常的那前端发送阶段是干净的问题在后面如果原始视图里就已经是quot;那你自己就是元凶别往下赖了。这一节我们假设前端侧需要有动作把几个高频的自伤行为讲清楚顺便给出可以直接抄的写法。2.1 Content-Type 与请求体的关系后端过滤器判断要不要转义时最重要的依据就是Content-Type。很多 XSS 过滤器的逻辑是这么写的如果是application/json说明是结构化数据不做 HTML 转义交给 Jackson 处理其他类型一律当作用户输入的文本转义。问题是前端经常在不经意间把Content-Type写错了。axios在data是普通对象时默认发application/json这个没问题。但如果你用了qs.stringify、$.param或者手动设了application/x-www-form-urlencoded后端就会把你的 JSON 字符串当成表单里的一个字段值来读。表单字段值是用户文本过滤器一转义引号就没了。更隐蔽的是multipart/form-data有些场景下用它传 JSON 字符串同样会踩坑。还有一种情况是charset没写或者写了ISO-8859-1。这个不会产生quot;但会导致中文变成问号或者乱码排查时容易和实体转义混淆。所以请求头统一写成这样最稳妥headers: { Content-Type: application/json;charsetUTF-8 }如果你用的是axios其实更推荐用axios.post(url, payload)这种形式让它自己决定头只在确实需要的时候手动覆盖。手动设置头的人越多出错的概率越大。2.2 重复序列化与表单化提交第二个高频坑是序列化两次。看下面这段代码// 错误示范序列化了两次 const payload { name: 张三 }; axios.post(/api/save, JSON.stringify(payload), { headers: { Content-Type: application/json } });axios看到data是字符串有的版本会再JSON.stringify一次最终发出去的是{\name\:\张三\}这样一个被引号包裹的字符串。后端 Jackson 反序列化成Map时会得到一个 key 是整段 JSON 的怪对象或者直接报MismatchedInputException。这个不是quot;问题但经常和quot;问题同时出现因为排查的人会以为是引号问题。第三个坑是把 JSON 存进 HTML。比如某些老项目用模板渲染页面把后端给的 JSON 塞进>div idapp>public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return value null ? null : cleanXSS(value); } Override public ServletInputStream getInputStream() throws IOException { String body readBody(super.getInputStream()); // 问题就出在这一行对 JSON body 也做了 HTML 转义 String cleaned cleanXSS(body); return new CachedServletInputStream(cleaned.getBytes(StandardCharsets.UTF_8)); } private String cleanXSS(String value) { return value.replaceAll(\, quot;) .replaceAll(, lt;) .replaceAll(, gt;) .replaceAll(, #39;); } }看到replaceAll(\, quot;)这一行了吗就是它把 JSON 的引号全换了。这段代码在表单提交场景下是有意义的因为表单参数最终会渲染到 HTML 页面上转义能防住存储型 XSS。但对 JSON 请求体做同样的事就完全说不过去了——JSON 是给程序解析的不是给浏览器渲染的转义不仅防不住任何东西还会破坏数据结构。判断标准很简单HTML 转义应该在输出到 HTML 上下文的那一刻做而不是在接收输入的时候做。接收阶段做转义等于把安全责任押在所有下游渲染场景都是 HTML这个假设上一旦下游是 JSON API、Excel 导出、短信模板这个假设就崩了数据也脏了。3.2 三分法排查流程不是每个项目都能立刻定位到 Filter我一般用下面这套流程快速收敛第一步看报错形态。如果后端报的是HttpMessageNotReadableException并且日志里有Unexpected character ( (code 38))基本可以确认 body 被改写过。因为 Jackson 读到quot;的第一个字符就懵了正常 JSON 里不可能出现裸露的。第二步二分法屏蔽 Filter。把web.xml或者FilterRegistrationBean里的 XSS 过滤器临时注释掉重启再发一次请求。如果问题消失锁定成功如果问题还在那就是网关或者前端的问题。这一步的代价是一次重启但能一下子切掉一半排查空间非常划算。第三步看 Filter 的执行顺序。如果项目里有多个 Filter注意它们的order。有些日志框架的RequestLogFilter在 XSS 过滤器之前执行打出来的日志是干净的让人误以为 Filter 没问题。这时候要把日志埋点往前挪或者在 XSS 过滤器内部直接打日志。第四步看是不是网关层。如果本地屏蔽 Filter 后问题消失但部署到测试环境又出现说明测试环境的网关或者反向代理有额外的改写规则。这种情况只能抓包或者找运维要网关日志。3.3 常见安全组件的转义逻辑很多团队不会自己写 Filter而是用现成的组件。这些组件的转义行为差别很大我把常见的几类列出来组件类型转义时机是否影响 JSON body典型表现自研 XSS Filter重写 getInputStream请求进入时会全量quot;自研 XSS Filter只重写 getParameter取参数时不会JSON 正常表单参数带实体快速开发脚手架自带的安全模块请求进入时部分版本会配置项里能关网关层 XSS 插件路由转发前会本地正常、线上异常模板引擎自动转义渲染时不会只影响页面输出重点说一下脚手架自带的那类。很多快速开发平台把 XSS 过滤做成了默认开启而且没有在文档里显著提示。你接手一个项目看到Content-Type是application/json写法也没问题但就是有quot;八成是它。解决办法通常是两个一是在过滤器配置里加上排除规则把/api/**或者所有 JSON 请求排除二是升级到新版本新版本的实现一般会判断Content-Type再决定要不要转义。另外提醒一句有些组件把排除路径做成白名单配置你需要显式列出不需要过滤的 URL。这种做法在接口数量多的时候很痛苦每加一个接口就要改配置。我更推荐按Content-Type判断因为这是自动的、不会漏的。4. 修复方案与代码落地定位清楚之后修复本身其实很简单难的是选一个不会在半年后又被别人改回去的方案。我见过太多团队出了问题就加一个replace(quot;, \)了事结果三个月后同样的 bug 换个字段又冒出来。下面给三个层次的方案从治标到治本。4.1 后端过滤器的改造按 Content-Type 放行最推荐的方案是在 Filter 里加一个判断JSON 请求直接放行不做任何 body 改写。Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; String contentType req.getContentType(); // JSON 和 XML 是结构化数据交给消息转换器处理禁止做 HTML 转义 if (contentType ! null (contentType.toLowerCase().contains(MediaType.APPLICATION_JSON_VALUE) || contentType.toLowerCase().contains(MediaType.APPLICATION_XML_VALUE))) { chain.doFilter(request, response); return; } chain.doFilter(new XssHttpServletRequestWrapper(req), response); }这段代码的意图很明确结构化数据的防 XSS 责任交给序列化框架和输出层过滤器只负责表单、查询参数这类会被直接渲染的文本。改完之后要注意回归测试别把原有的防 XSS 能力整个关掉了。测试方法是往表单字段里提交scriptalert(1)/script看页面上是不是还以文本形式显示而不是被当脚本执行。如果表单字段的转义还在生效说明改造是安全的。如果你的框架支持配置化的排除规则也可以这样写Bean public FilterRegistrationBeanXssFilter xssFilter() { FilterRegistrationBeanXssFilter bean new FilterRegistrationBean(); bean.setFilter(new XssFilter()); bean.addUrlPatterns(/*); // 让 JSON 接口走独立链路或者在 Filter 内部按 Content-Type 判断 bean.setOrder(Ordered.HIGHEST_PRECEDENCE 10); return bean; }顺序上有个细节XSS 过滤器要尽量靠前避免被其他过滤器读走 body 之后再处理那样会读到已经被消费的流。4.2 前端提交规范与参数校验前端这边要做的是守规矩让后端没有理由做特殊处理。我把规范整理成几条团队可以直接用的约定第一所有 JSON 接口统一用application/json;charsetUTF-8不允许出现application/x-www-form-urlencoded传 JSON 的情况。第二封装一个统一的请求函数把JSON.stringify收敛到一处业务代码只传对象不传字符串。第三禁止在请求拦截器里对 body 做任何形式的字符串替换。第四如果确实需要在 HTML 里传 JSON用>async function postJson(url, payload) { const res await fetch(url, { method: POST, headers: { Content-Type: application/json;charsetUTF-8 }, body: JSON.stringify(payload) }); if (!res.ok) { throw new Error(HTTP ${res.status}); } return res.json(); }同时建议加一层开发期的自检在请求发出前用JSON.parse(body)做一次往返验证。如果序列化出来的字符串自己都解析不回来说明 payload 里混进了非法字符这时候报错比发出去之后被后端打回来要快得多。4.3 兜底方案反转义与双重转义处理有时候你没法改过滤器比如那是公司统一的安全组件或者改配置要走半个月流程。这时候只能在接收侧做兜底反转义。做法是自定义一个 Jackson 的JsonDeserializerString对每个字符串字段做 HTML 反转义public class HtmlUnescapeDeserializer extends JsonDeserializerString { Override public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String raw p.getValueAsString(); if (raw null) { return null; } String result raw; // 循环处理兼容双重转义 for (int i 0; i 3; i) { String next StringEscapeUtils.unescapeHtml4(result); if (next.equals(result)) { break; } result next; } return result; } }这里用了StringEscapeUtils.unescapeHtml4注意它是commons-text包里的类commons-lang33.6 之后的版本已经把这个方法标记为废弃别引错包。循环三次是为了兼容双重甚至三重转义但必须设上限否则遇到构造好的恶意输入可能导致长循环。关于amp;quot;这种双重转义判断方式很简单先做一次 unescape如果结果里还含有quot;说明至少转义了两层。这个情况通常意味着前端和后端各转了一次或者网关和应用各转了一次。按长度膨胀比算双重转义的文本长度大约是原始文本的 10 到 11 倍非常容易识别。必须强调一点兜底反转义只是应急不是常规方案。它意味着你的过滤器和你的业务逻辑在打对台一个转一个解任何一环变了你都会再出问题。而且对全量字符串做反转义等于绕过了原有的 XSS 防护如果你的输出层没有做好转义反而引入了真正的前端注入风险。用它的时候一定要在代码注释里写清楚这是临时方案关联工单号等过滤器改造完就删。5. 常见问题速查与避坑经验排查这个问题花了这么多年我发现真正浪费时间的不是修复而是误判。下面把最容易误判的几种情况整理成表出问题的时候直接查表对号入座能省掉一半时间。5.1 典型问题排查表现象最可能的根因快速验证方式处理方向本地正常测试环境出现quot;环境网关或反向代理有改写规则对比两环境的抓包、网关日志找运维确认网关规则所有接口都出现quot;全局 XSS 过滤器注释 Filter 后重试按 Content-Type 放行只有某个字段出现quot;该字段来自 HTML 属性拼接查看前端取值代码改用datasetJSON.parseamp;quot;双重实体两层链路都在转义统计 body 长度膨胀比关掉其中一层报Unexpected character ()body 被改写打RequestBody String原样日志定位改写层报 415 不支持的类型Content-Type不匹配看请求头统一为application/json中文变问号而非实体字符集写成了 ISO-8859-1看请求头和响应头统一 UTF-8表单提交正常JSON 提交异常过滤器只对 body 转义对比两种提交的 Content-Type过滤器跳过 JSON这张表里我个人踩得最惨的是第一行。当时本地怎么都复现不出来最后发现是测试环境的网关有一版防 XSS 的策略规则更新的时候没人通知开发。这种问题没有任何应用日志能帮到你只能靠对环境的敏感度。5.2 实操心得与注意事项最后分享几条纯经验都是文档里不会写的东西。第一条保存一份被污染的原始报文样本。把quot;版本和正常版本的报文各存一份到 issue 里后面无论是找运维还是找安全团队沟通拿出来一对就清楚了比口头描述高效十倍。第二条排查时优先怀疑自己团队最近改过的东西。quot;问题往往是在某次安全加固或者框架升级之后突然出现的。翻一翻最近两周的提交记录搜一下escape、htmlEscape、cleanXSS、replace这些关键词常常一眼就看到了。第三条不要用正则去修 JSON。我见过有人写body.replace(/quot;/g, )在业务代码里补结果遇到本来内容里就有quot;的字段比如用户真的输入了这几个字符或者富文本内容里就是这么写的数据被改坏了。反转义要用标准库要限定作用范围不要在整段 body 上做全局替换。第四条接口交接的时候明确写清楚数据格式约定。在 API 文档里加一行请求体为纯 JSON服务端不对 JSON 结构做 HTML 转义前端也不需要做任何转义。这一行字能省掉未来无数次的联调扯皮。很多时候两边的工程师都是按自己以为的惯例在写代码没人说破就一直错着。第五条如果团队有安全测试环节提前把这类场景加进测试用例。让安全同学用带script和带引号的 JSON 做一轮验证验证的结果应该是脚本内容能正常存进数据库并在页面上以文本形式显示而 JSON 结构始终保持完整。这个用例一跑过滤器改对了没有立刻见分晓。第六条关于富文本内容。用户提交的内容里本身就可能含有quot;比如他粘贴了一段 HTML 源码。这时候你不能无脑反转义否则会把用户的原始内容改掉。正确做法是把外层 JSON 结构的引号和值内容里的实体区分开——外层由序列化框架保证值内容原样存储渲染时按需转义。这个边界意识比具体技术方案更重要。我个人的体会是这类问题表面上是编码问题本质上是上下文边界没有划清楚。数据从输入到存储到渲染每个阶段的上下文不同转义策略就应该不同。把所有阶段都当成 HTML 来处理短期看省事长期看必然出问题而quot;只是它暴露出来的第一个症状。下次你再看到后台日志里那些整整齐齐的quot;别急着加反转义代码先问一句到底是谁在哪个上下文里觉得有必要把 JSON 引号转成 HTML 实体。找到这个人问题就解决了一半。