1."和\"是两条完全不同的转义链路,先别混为一谈
前端传 JSON、后台收到",这个现象我前后遇到过不下五次,每次的根因都不一样。刚接手的同学最容易犯的错,是把\"(反斜杠加引号)和"(HTML 实体)当成同一类东西,然后在上网搜出来的“万能反转义”代码里来回试,结果越修越乱。所以动手之前,得先把这两条链路分清楚:JSON 转义是规范行为,HTML 实体转义则一定是某个环节主动“动过手”。
\"的出现是合法的。你调用JSON.stringify把一个普通对象序列化成字符串,字符串值里的双引号必然会被加上反斜杠,否则解析器分不清哪个引号是结构、哪个引号是内容。这是 JSON 规范(RFC 8259)里白纸黑字写死的,后端用 Jackson、Gson、Fastjson 反序列化时会自动还原,你根本不需要做任何事。
"就完全是另一回事了。它属于 HTML 实体(HTML Entity)体系,"是双引号"的实体写法,&是&,<是<。这套东西是给 HTML 用的,作用是让浏览器在渲染时正确显示那些会跟标签语法打架的字符。JSON 解析器不认识",它只会把它当成五个普通字符。所以一旦 JSON 报文里混进了",逻辑上只有一种可能:请求体在被解析之前,被某个环节按 HTML 的规则转义了一遍。
1.1 从 Network 面板里看清发出去的到底是什么
排查第一个动作,永远是打开浏览器开发者工具的 Network 面板,找到那一条请求,看Request Payload或者请求体原文。这里有个特别容易让人误判的细节:Chrome 的网络面板在展示 JSON 时,会做一次“美化”和友好化处理,你在面板里看到的内容和实际发出去的字节流可能不完全一样。想看真实字节,得右键那一行,选择Copy as fetch或者Copy as cURL,粘到文本编辑器里看。
如果你在 Network 里看到的请求体是干干净净的{"title":"测试","content":"他说\"你好\""},那前端是清白的,问题出在后端。如果你看到的就是{"title":"测试"...},那前端就已经被污染了,往下去查前端哪一步动了手。这一步不做,后面全是猜。
注意:有些同学习惯用
console.log(JSON.stringify(obj))来“验证”发出去的内容,这个其实不可靠。控制台输出的是对象序列化的结果,跟网络层实际发送的 body 未必一致,中间还可能隔着 axios 的转换器、拦截器、请求包装。以 Network 面板的 Copy as fetch 为准。
1.2 JSON 层的\"是规范行为,不该去动它
我把 JSON 转义的常见形态列一下,方便对照,省得你看到反斜杠就紧张。
| 原始内容 | JSON.stringify 之后 | 说明 |
|---|---|---|
他说"你好" | 他说\"你好\" | 内容里的引号被转义,正常 |
| 换行符 | \n | 控制字符被转义,正常 |
反斜杠\ | \\ | 自身转义,正常 |
| 制表符 | \t | 控制字符,正常 |
看到这张表你就明白了:\"的源头是内容里本来就有引号,而 JSON 序列化器为了保证语法正确做了转义。后端反序列化时会自动还原成他说"你好"。如果你在后端拿到的字段值是他说\"你好\"(带着反斜杠),那说明后端是用 String 接的整个 body 又没解析,这属于另一类问题,跟"不是一回事。
1.3 HTML 实体层的"一定是被主动种进去的
"不可能凭空出现。在 Java 世界里,能产生它的最常见调用就这几个:
// Spring 自带,最常用 org.springframework.web.util.HtmlUtils.htmlEscape("\"") // 返回 " // Apache Commons Text org.apache.commons.text.StringEscapeUtils.escapeHtml4("\"") // 返回 " // 老版本 commons-lang org.apache.commons.lang3.StringEscapeUtils.escapeHtml("\"") // 返回 "一旦某个环节把上面的任一个调用套在了请求体上,{"name":"张三"}就会变成{"name":"张三"}。这时候传给 Jackson 会怎样?直接抛反序列化异常,报错信息往往就是热词里那个failed to deserialize the json body into the target type。就算你后端图省事用 String 收下整段 body,存进数据库的也是带"的脏数据。
1.4 两种转义叠加之后的样子最迷惑人
最让人头大的是叠加转义。内容里本来有个引号,前端序列化成\",中间某层又做一次 HTML 转义,\没被处理但"变成了",于是你看到\"。再叠加一层,&又变成&,就成了&quot;。这种“多层转义”在富文本、老后台系统里非常常见,你手动反转义一次根本不够,得转义几次反几次。
判断当前是哪一层,有个土办法:看&的情况。如果只有",说明只做了一次 HTML 转义;如果出现&quot;,说明做了两次;如果连&都被处理成了&而引号还是原始引号,那说明转义发生在 JSON 序列化之外。这个小技巧能帮你快速缩小范围。
2. 前端这边:从对象到请求体,每一步都可能悄悄加料
确认前端有嫌疑之后,别急着改代码,先沿着数据流动的路径走一遍,因为从你手里的 JS 对象到最终发出的字节流,中间至少有三到四个地方可能被“加料”。很多人一上来就怀疑JSON.stringify,其实它是最无辜的那个,真正捣乱的是手工拼接、重复编码和某些组件的默认行为。
2.1 先用 Copy as fetch 把真实报文钉死
前面说过了,这一步是基准。拿到真实的 fetch 代码后,重点看两处:一是headers里的Content-Type,二是body的确切内容。如果Content-Type写的是application/json而 body 里出现",那前端就有问题。如果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 时会把"还原成",这个反而是对的。真正会出问题的是反向操作:用innerHTML读取一个包含引号的值,或者用某些模板引擎(如 Thymeleaf、JSP 的c:out)输出到页面,这些地方默认会做 HTML 转义。
2.4 富文本框和组件库的自主转义
富文本编辑器是个重灾区。像某些基于 contenteditable 的编辑器,内部会把内容序列化成 HTML 再存起来,为了让 HTML 合法,引号属性会被转成"。如果这个 HTML 字符串被当成 JSON 的一个字段值发送,里面自然就带着"了。这种情况下"其实是内容的合法组成部分,不该被反转义,反转义了反而破坏结构。
判断方法是看你的业务语义:如果字段值本来就是一段 HTML,那"属于内容,要在渲染层处理;如果字段值应该是纯文本,那"就是被误伤的,需要在入库前还原。分不清这一点,就会陷入“改了这边坏那边”的循环。
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,前端再取出来展示时,反斜杠就会显示出来,用户看到的就是他说\"你好\"。这不是",但道理相通:用 String 接 JSON body,等于放弃了框架帮你做的反序列化。
一个常见的错误修复方式是,在方式二里再手动JSON.parse(body)一次,结果因为 body 里已经被转义过,parse 出来还是不对。正确做法是用对象接,或者明确知道自己在做什么才用 String。
3.2 全局 XSS 过滤器对 JSON body 的误伤
这是"最可能的来源。网上流传的很多 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":"张三"}经过这一手,变成{"name":"张三"},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 模板引擎与日志输出阶段的转义
如果你发现数据库里存的是好的,但页面显示或接口返回时变成了",那转义发生在输出阶段。典型的是 JSP 的<c:out>、Thymeleaf 的th:text,它们默认对变量做 HTML 转义。这本身是安全设计,防止 XSS,但它只该用于 HTML 输出,不该用于接口返回的 JSON。
还有一种更隐蔽的:日志框架或 AOP 切面在打印请求参数时做了转义,把转义后的内容又回写到了某个地方。这种 bug 极其难查,因为日志里看到的是转义后的内容,但你不知道是日志打印时转的,还是数据本来就脏。
3.4 数据库落库时的二次转义
有些老项目在 DAO 层或者 ORM 拦截器里加了“防注入”处理,对字符串做了 HTML 转义再入库。如果这个拦截器跟 XSS 过滤器叠加,就会出现双重转义&quot;。定位方法是直接连数据库看原始值,如果库里就是",说明问题在入库前;如果库里是好的,说明问题在读取或输出时。
| 观察点 | 库里是好的 | 库里是" |
|---|---|---|
| 问题位置 | 输出/渲染阶段 | 写入阶段 |
| 排查方向 | 模板引擎、接口返回序列化 | 过滤器、DAO 拦截器 |
| 修复方式 | 换文本渲染方式 | 让过滤器放行 JSON |
4. 一层层剥开:一次完整的定位过程复盘
光讲原理容易飘,我拿一个自己实际处理过的案例走一遍。现象是:一个后台管理系统,用户在富文本框里输入带引号的内容,保存后再打开,引号位置显示成了"。这个 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 按链路顺序打日志
打日志要沿着链路,从入口到出口,一个点都别跳:
- 浏览器 Network:请求体是
{"content":"他说\"你好\""},前端序列化正常。 - 后端 Controller 入口:
log.info打出来是他说"你好",说明进 Controller 之前就已经被转义了。 - 过滤器链:在过滤器里打印
getInputStream读到的内容,确认是{"content":"他说"你好""}。 - 定位到 XSS 过滤器:代码里确实有一个对
getInputStream全文HtmlUtils.htmlEscape的实现,且没有区分Content-Type。
到这一步,根因就钉死了:XSS 过滤器把 JSON body 的结构和内容一起转了。
4.3 从现象反推转义发生的层
反过来,如果你手上只有一个现象,没有日志,也可以按下面的顺序推断:
- 现象是
"出现在数据库里 → 写入之前就被转义,重点查过滤器和 DAO 拦截器。 - 现象是数据库干净、接口返回带
"→ 输出阶段被转义,查模板引擎和序列化配置。 - 现象是后端接口返回干净、前端页面显示
"→ 前端渲染方式的问题,见第 6 节。 - 现象是
&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 '%"%'; -- 确认无误后再更新,替换前后都留一份备份 UPDATE note SET content = REPLACE(content, '"', '"') WHERE content LIKE '%"%';如果存在双重转义&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
如果你的页面显示",很多时候是因为用错了渲染方式。用innerHTML插入内容时,浏览器会把字符串当 HTML 解析,实体字符会被当作标记处理;用textContent或者 Vue/React 的文本插值{{ }},则会按纯文本处理,"会原样显示。既然"是脏数据,那就应该在数据层解决,而不是靠渲染层“恰好”把它显示成引号——那是掩盖问题,不是解决问题。
// 不推荐:把带实体的脏数据塞给 innerHTML el.innerHTML = dirtyContent; // 推荐:用文本节点插入,同时在数据层保证 content 是干净的 el.textContent = cleanContent;最后说个我自己的体会。这类转义问题,十有八九是“某个中间层想做好事”惹出来的——XSS 过滤器想防护、模板引擎想安全、编码函数想兼容,结果都不知道对方也在动手,叠在一起就出了乱子。所以遇到这类问题,别急着写反转义,先沿着数据流从源头到终点走一遍,把每一层“是否动过手”列出来,往往还没走到终点,源头就自己冒出来了。