news 2026/9/10 19:04:08

Gson默认转义=和?详解HTML安全转义及关闭方法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Gson默认转义=和?详解HTML安全转义及关闭方法

有次在给客户做开放平台对接,回调报文里某个url字段的值长这样:

{"url":"https://example.com/login?redirect\u003d/home\u0026from\u003dwechat"}

我第一反应是对方把 URL 编码和 JSON 转义搞混了,怎么好端端的等号变成了\u003d、与号变成了\u0026。后来跟着报错信息一路查到 Gson 源码,才发现这根本不算 bug,而是 Gson 从设计上就默认开启的一个"隐藏策略"——HTML 安全转义。标题里写的"转译",其实就是平时说的转义(escape)。

今天就把这个事彻底说清楚:Gson 到底会给哪些字符"改头换面",改动背后的动机是什么,哪些场景会因此踩坑,以及应该怎么按需关掉它。

1. 先搞清楚:Gson 到底会把哪些字符"改头换面"

1.1 一段最简单的代码,看 Gson 输出的真实效果

先做一个最直接的实验。定义一个非常普通的 POJO,里面有两个字符串字段,一个放 URL,一个放普通标题:

import com.google.gson.Gson; import com.google.gson.GsonBuilder; public class GsonEscapeDemo { static class Param { public String url; public String title; } public static void main(String[] args) { Param p = new Param(); p.url = "https://example.com/login?redirect=/home&from=wechat"; p.title = "Tom & Jerry <script>alert(1)</script>"; Gson defaultGson = new Gson(); System.out.println("默认: " + defaultGson.toJson(p)); Gson safeGson = new GsonBuilder().disableHtmlEscaping().create(); System.out.println("关闭: " + safeGson.toJson(p)); } }

运行结果如下:

默认: {"url":"https://example.com/login?redirect\u003d/home\u0026from\u003dwechat","title":"Tom \u0026 Jerry \u003cscript\u003ealert(1)\u003c/script\u003e"} 关闭: {"url":"https://example.com/login?redirect=/home&from=wechat","title":"Tom & Jerry <script>alert(1)</script>"}

同样一个对象,默认 Gson 和关闭 HTML 转义的 Gson,输出差别很大。默认情况下,等号=被替换成了\u003d,与号&被替换成了\u0026,小于号<被替换成了\u003c,大于号>被替换成了\u003e。而?/这些字符则原样保留,没有被处理。

这就是很多人第一次遇到问题时最困惑的地方:为什么有的字符被转义,有的字符不转义?答案要看 Gson 内置的字符替换表。

1.2 完整字符清单:除了 =、&,还有这些

Gson 的字符串转义其实分两类:一类是 JSON 规范本身要求的强制转义,另一类是 Gson 在默认配置下额外追加的 HTML 安全转义。这两类很容易混在一起,下面分开列。

先看 Gson 默认会比 JSON 规范多做的那部分——HTML 敏感字符:

原字符含义Gson 默认输出说明
=等号\u003d等号在 HTML 标签属性中用于绑定属性值
&与号\u0026与号是 HTML 实体的起始字符
<小于号\u003c小于号是 HTML 标签的起始字符
>大于号\u003e大于号是 HTML 标签的结束字符
'单引号\u0027单引号在 HTML 属性中可用于包裹属性值
"双引号\"双引号在 JSON 中必须转义,Gson 按标准处理

再看 JSON 规范本身强制要求转义的字符,这部分任何 JSON 库都会处理,不是 Gson 特有的:

原字符输出说明
"\"防止与字符串定界符冲突
\\\反斜线本身就是转义符号
退格 0x08\b控制字符
换页 0x0C\f控制字符
换行 0x0A\n控制字符
回车 0x0D\r控制字符
制表 0x09\t控制字符
0x00 ~ 0x1F 其他\uXXXX统一用 Unicode 码点表示

所以回答标题里的问题:Gson 遇到会转义的字符,除了 =、&,还有 <、>、'、" 这五个 HTML 敏感字符,以及 JSON 规范规定的控制字符和反斜线。中文、空格、%?#@这些都不在默认转义范围内。

1.3 转义形式的本质:\u003d 是 Unicode 码点,不是 URL 编码

\u003d这种形式很多人一看就慌,以为是编码乱掉了,其实它只是 Unicode 码点的十六进制表示。等号在 Unicode 表里的码点是 U+003D,所以 Gson 用\u003d表示;与号是 U+0026,所以输出\u0026。这是 JSON 标准里合法的字符串转义方式,任何支持 JSON 的解析器都能识别。

这里有个非常容易踩的 Java 源文件坑。在 Java 代码里写"\u003d",编译期就会直接变成字符串"=",因为 Java 编译器会先处理 Unicode 转义。但如果你在 JSON 字符串里看到的是\u003d,想在 Java 源码里构造这个 JSON 字面量,必须写成"\\u003d",也就是把反斜线本身转义一下。

// 注意这行输出的是等号 System.out.println("\u003d"); // = // 这行输出的是 6 个字符:\u003d System.out.println("\\u003d"); // \u003d

很多同学写测试用例时发现怎么都对不上,就是栽在这上面。记住一个原则:你在日志、抓包里看到的\u003d,在 Java 字符串里要表达它,就用双反斜线。

2. 为什么 Gson 要默认转义 = 和 &?——HtmlSafe 的设计动机

2.1 从 Web 安全的起源说起

Gson 是 Google 出的库,早期大量使用场景是把 Java 对象序列化成 JSON,然后直接嵌到 HTML 页面的<script>标签里,或者塞进<input><div>的某个属性中。在这种场景下,<>这类字符非常危险。

举个典型例子。假如业务字符串里包含</script>

{"title":"</script><script>alert(1)</script>"}

如果这段 JSON 被直接嵌入到页面的<script>区块里,浏览器解析 HTML 时会把</script>当成脚本结束标签,后面的<script>alert(1)</script>就会变成真正的可执行脚本,形成 XSS 漏洞。

Gson 把<转义成\u003c>转义成\u003e之后,这段内容就变成了:

{"title":"\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e"}

浏览器拿到这段字符时,里面没有真实的<>,自然不会形成 HTML 标签结构。所有字符都只是普通文本,XSS 注入就失效了。

2.2 等号和与号为什么也要转义

<>转义的理由很充分,那=&呢?它们不参与标签结构,为什么也要动?

因为 Gson 序列化出来的 JSON 不只会被放进<script>里,还可能被放进 HTML 标签属性里,比如:

<input type="text" value='{"url":"https://example.com/?a=1&b=2"}' />

在 HTML 属性值里,&是 HTML 实体的起始字符。如果 JSON 中的&没有被处理,浏览器解析这个属性时可能把&b当作一个实体引用的开头,导致属性值被错误截断或解析,页面展示就乱了。=在属性中用于连接属性名和属性值,在某些宽松解析环境下也可能引起属性边界混乱。

Gson 索性把<>&='"这一整组 HTML 特殊字符全部用\uXXXX\"形式输出。这样无论这段 JSON 被塞到 script 标签里、HTML 属性里,还是别的什么 HTML 结构中,本质上都只是一串"不会触发任何 HTML 结构语义"的普通字符。这个策略在 Gson 里就叫HtmlSafe,从设计目的看,它本质上是面向 Web 页面输出场景的防注入策略。

2.3 哪些场景会因此踩雷

HtmlSafe 策略对"JSON 要嵌进 HTML 页面"的场景是福,对"JSON 只做纯数据交换"的场景却常常是祸。最典型的踩雷场景包括:

  • URL 参数拼接:拿到 Gson 输出的 JSON 字符串,直接拼到 GET 请求的 query 里,结果服务端收到的参数里全是\u003d\u0026,解析参数时完全对不上。
  • 签名 / 哈希计算:对 JSON 字符串做 MD5、HMAC 或 SHA 摘要时,A 系统用的字符串里=已经被转义成\u003d,B 系统验签时用的是原始等号,两边算出来的摘要永远不一致。
  • 日志检索:在日志平台里搜&,怎么都搜不到,因为落库的 JSON 里存的是\u0026
  • 字符串匹配:用indexOf("&")去 JSON 原文里找与号,返回 -1,排查半天发现它变成了\u0026
  • 跨语言 / 跨库协作:A 端用 Gson 生成 JSON,B 端用别的库解析并重新序列化,两边对同一个对象生成的字符串长得不一样,导致数据对账失败。

这里还要特别强调一点:\u003d对于 JSON 标准解析器来说完全合法,fromJson会正确还原成=。所以真正的坑从来不是"Gson 序列化出来的东西别人解析不了",而是"你的下游没有经过 JSON 解析,直接把 JSON 字符串当普通文本去用"。

3. 实用解法:什么时候该关,什么时候不该关

3.1 最简单直接的方式:disableHtmlEscaping()

Gson 的GsonBuilder提供了disableHtmlEscaping()方法,调用之后,这个 Gson 实例生成 JSON 时就不会再把 HTML 字符转成\uXXXX形式:

Gson gson = new GsonBuilder() .disableHtmlEscaping() .create();

改完之后,前面例子里的输出变成:

{"url":"https://example.com/login?redirect=/home&from=wechat","title":"Tom & Jerry <script>alert(1)</script>"}

=&<>'都保持原样,只有 JSON 规范强制要求的"\、换行、控制字符等仍然会被转义。也就是说,关闭HtmlSafe并不会让 Gson 输出非法 JSON,它依然是一个完全符合 JSON 标准的字符串。

这里要提醒一句:disableHtmlEscaping()是 Gson 实例级别的全局配置,一旦设置,这个实例所有toJson输出都不再执行 HTML 转义。如果你的项目同时存在"给前端页面嵌入"和"纯接口数据交换"两种用法,就要评估一下是否所有调用方都接受关闭后的行为。

3.2 如果只是个别字段需要保留特殊字符:字段级自定义处理

有时候只想让某个字段输出原始的=&,其他字段保持默认行为,那么全局关闭可能影响面太大。这种情况下可以考虑字段级的JsonSerializer/JsonDeserializer

Gson 从 2.8 开始,JsonWriter提供了setHtmlSafe(boolean)方法,可以更细粒度地控制某个值输出时是否执行 HTML 转义:

import com.google.gson.*; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.IOException; public class RawUrlAdapter extends TypeAdapter<String> { @Override public void write(JsonWriter out, String value) throws IOException { if (value == null) { out.nullValue(); return; } // 这个字段单独关掉 HTML 转义 out.setHtmlSafe(false); out.value(value); } @Override public String read(JsonReader in) throws IOException { return in.nextString(); } }

注册时用@JsonAdapter注解挂在字段上即可:

public class Param { @JsonAdapter(RawUrlAdapter.class) public String url; public String title; }

这样url字段序列化时不会被转义成\u003d,而title字段仍然使用 Gson 默认的 HtmlSafe 行为。不过说实话,这种字段级方案在简单场景下有点杀鸡用牛刀,而且setHtmlSafe(false)会临时改变 writer 的状态,如果同一个 writer 后续还要写别的值,要注意状态是否会恢复。Gson 内部会在value()写入后再恢复,但你自己实现的 Adapter 最好也做一次状态保存和恢复,避免污染后续输出。

如果只是"输出给程序的 JSON 不想看到\u003d",我个人建议优先用全局disableHtmlEscaping(),简单、直观、可预期。字段级适配器适合那种"大部分内容要面向 HTML 输出,只有个别字段要保留原始字符"的混合场景。

3.3 序列化与反序列化的对称性:转得回来吗

很多人担心一个问题:Gson 把&转成\u0026,那用fromJson读回来的时候能还原成&吗?答案是肯定的。

String json = "{\"url\":\"https://example.com/login?redirect\\u003d/home\\u0026from\\u003dwechat\"}"; Param parsed = new Gson().fromJson(json, Param.class); System.out.println(parsed.url); // 输出: https://example.com/login?redirect=/home&from=wechat

\uXXXX是 JSON 字符串中合法的转义序列,JsonReader在读字符串时会把它翻译成对应字符。所以同一个 Gson 实例内部,"转出去"和"读回来"是完全对称的。

真正的不对称来源于"不同的序列化配置"。A 系统用默认 Gson 转出去得到\u0026,B 系统用关闭 HtmlSafe 的 Gson 从同一个对象生成得到&,两边对同一份数据的 JSON 表达不一致,就会在字符串比对、签名校验、消息去重等环节暴露出问题。这也是为什么我建议团队项目里把 JSON 工具统一成一个公共类,并且明确到底开不开 HtmlSafe,而不是各写各的new Gson()

4. 实战排查:等号与与号引发的签名校验失败全过程

4.1 场景还原:URL 参数进对象,再出对象,sign 变了

之前接过一个真实案例,A 系统把业务数据封装成 JSON,作为 URL 参数传给 B 系统的开放接口。为了防篡改,双方约定对 JSON 字符串加盐后做 MD5,把摘要放在sign字段里。A 系统本地自测一切正常,但 B 系统一直报验签失败。

我拿到两边的报文一对比,发现 A 系统签名时用的字符串里,URL 字段长这样:

https://example.com/login?redirect=/home&from=wechat

而 B 系统收到的参数,URL 字段长这样:

https://example.com/login?redirect\u003d/home\u0026from\u003dwechat

A 系统传来的sign是按原始等号、与号那版计算的;B 系统验签时用的是 URL 解码后的 JSON 字符串,里面已经是\u003d\u0026了,两边摘要自然对不上。

4.2 排查链路:从抓包到源码

整个过程排查了将近两个小时,现在复盘一下完整链路,很有代表性。

第一步,抓包看原始报文。发现 B 系统收到的 JSON 字符串里出现大量\u003d\u0026。我一开始以为是 URL 编码问题,因为=&在 query string 里本身就是保留字符,但仔细看发现转义形式不是%3D%26,而是反斜线加小写 u 的 Unicode 形式。这说明问题不在 URL 编码层。

第二步,用indexOf("&")在报文字符串里找与号,返回 -1。当时的报文内容是"...redirect\u003d/home\u0026from\u003dwechat...",我直接用字符串搜索&当然找不到。这个环节很容易误导人,让人以为是内容被截断或者编码错了。

第三步,把 JSON 字符串原样打到日志里,确认这就是 Gson 序列化的产物。排查到这一步才意识到,真正要回答的问题不是"URL 为什么编成这样",而是"Gson 为什么输出\u003d"。

第四步,去 Gson 源码里搜替换表。在com.google.gson.stream.JsonWriter中可以看到内置的字符替换数组,里面有=&<>'等字符的\uXXXX映射。看到这些映射的注释,确认是 HtmlSafe 策略在起作用。

第五步,用GsonBuilder().disableHtmlEscaping().create()替换原先的new Gson(),重新生成报文,\u003d\u0026消失,验签通过。

这个案例给人最大的启发是:排查转义类问题,不要光盯着字符串内容本身,要先判断这个字符串是从哪个库、哪个配置出来的。看到\uXXXX,第一反应应该是"哪个工具做了 Unicode 转义",而不是急着做 URL 解码。

4.3 不同解法在真实项目里的取舍

针对这种场景,可选的方案其实有几种,各有适用条件。

  • 方案一:全局关闭 HtmlSafe。最省事,适合绝大多数纯后端 JSON 数据交换场景。只要你的 JSON 不会直接嵌入 HTML 页面,关闭它基本没有副作用。
  • 方案二:字段级 TypeAdapter。适合个别字段有特殊需求、整体还要保留 HtmlSafe 的场景,但代码复杂度高一些,而且要处理好 writer 状态。
  • 方案三:序列化后再做字符串替换,把\u003d手工替换回=。这种方法非常不推荐,因为你要把所有可能出现的转义序列都处理一遍,漏一个就埋雷,而且在某些业务字段本身就需要\u003d字面量时会误伤。
  • 方案四:不让 Gson 处理,改用 Jackson 或 Fastjson 做序列化。这是"换库"思路,但为了一个转义配置换掉整个 JSON 框架,代价偏大,除非项目本来就在做迁移。

我的建议很简单:先确认 JSON 的消费方。如果消费方全部是程序,没有人把 JSON 字符串直接嵌到 HTML 里,那就全局关闭。如果 JSON 要同时服务于页面输出和接口调用,建议拆成两个 Gson 实例,一个开 HtmlSafe,一个关掉,按用途选择。

5. 同类库横向对比:Jackson、Fastjson 的转义策略

5.1 默认转义行为对比表

Gson 的 HtmlSafe 行为在 Java 生态里并不是通用的,很多用了多年 Jackson、Fastjson 的团队第一次见\u003d都会愣一下。下面这个对比表能帮你快速定位不同库的默认表现:

字符Gson(默认)Jackson(默认)Fastjson(默认)
=\u003d原样输出原样输出
&\u0026原样输出原样输出
<\u003c原样输出原样输出
>\u003e原样输出原样输出
'\u0027原样输出原样输出
"\"\"\"
中文原样输出原样输出原样输出

注意,Jackson 和 Fastjson 并不是完全不处理特殊字符,它们会遵守 JSON 规范,把双引号、反斜线、控制字符转义掉,但不会像 Gson 那样额外处理 HTML 敏感字符。所以同样一个对象,Gson 和 Jackson 输出的 JSON 字符串可能存在差异,但对 JSON 解析器来说都是等价的。

5.2 跨库协作时最容易遇到的问题

跨库协作的麻烦点在于"字符串不一致"而不是"解析不兼容"。A 端用 Gson,B 端用 Jackson,A 端序列化出的 JSON 里=\u003d,B 端收到后用自己的 Jackson 重新序列化,得到的是原始=。于是同一份数据在不同系统里留下了不同版本的字符串。

这种情况在下面几类业务中特别容易爆雷:

  • 消息队列里的消息 body 被多个消费者消费,消费者对消息做内容去重或做摘要。
  • 接口签名/验签机制直接作用在 JSON 字符串上。
  • 系统间对账,用 JSON 字符串的哈希值作为记录指纹。
  • 数据库里同一个 JSON 字段,有的系统写入的是转义版,有的系统写入的是原样版,查询和比对时产生脏数据。

解决思路有两个方向:一是统一所有系统的 JSON 库和配置,比如都用disableHtmlEscaping();二是不对 JSON 字符串本身做签名、去重、哈希,而是对解析后的业务字段做这些操作。第二种其实更稳,因为 JSON 的键顺序、转义风格在不同版本之间都可能变化,把字符串当作业务指纹本身就是脆弱的。

5.3 一个可以复用的 JSON 工具类

既然聊到了统一配置,分享一个我项目里常用的工具类写法。核心就一个:全局单例 Gson 实例,显式关闭 HtmlSafe,避免到处new Gson()导致配置不一致。

import com.google.gson.Gson; import com.google.gson.GsonBuilder; public final class JsonUtils { private static final Gson GSON = new GsonBuilder() .disableHtmlEscaping() .create(); private JsonUtils() { } public static String toJson(Object obj) { return GSON.toJson(obj); } public static <T> T fromJson(String json, Class<T> clazz) { return GSON.fromJson(json, clazz); } public static <T> T fromJson(String json, Type type) { return GSON.fromJson(json, type); } }

如果一个项目里同时存在"页面嵌入"和"接口数据交换"两种需求,就建两个单例:

public class GsonHolder { // 页面嵌入场景:保留 HtmlSafe public static final Gson HTML_SAFE_GSON = new GsonBuilder().create(); // 数据交换场景:关闭 HtmlSafe public static final Gson PLAIN_GSON = new GsonBuilder().disableHtmlEscaping().create(); }

然后在具体代码里按场景选择,而不是每次用new Gson()现造实例。这样团队里任何人的输出行为都是一致的,排查问题的时候能少一大半干扰。

最后再分享一个我自己的小习惯。只要是新建 Java 项目,JSON 工具类里我会默认写disableHtmlEscaping(),除非这个项目明确有"把 JSON 嵌入 HTML 页面"的需求。绝大多数后端服务、微服务网关、消息队列的 JSON 消费端都是程序,不是浏览器,HtmlSafe 带来的转义只会增加排查成本和字符串不一致风险。当然,如果你还在用老版本的 Gson,建议先看一眼JsonWriter源码里有没有setHtmlSafe方法,老版本 API 上可能略有差异。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 19:04:01

实时日志管理系统架构设计与优化实践

1. 实时系统日志管理的核心价值 日志就像系统的"黑匣子"&#xff0c;记录着每一次心跳、每一次异常和每一次关键操作。在分布式架构和微服务盛行的今天&#xff0c;传统的日志管理方式已经捉襟见肘。我曾经历过一次线上事故——某个核心服务突然崩溃&#xff0c;团队…

作者头像 李华
网站建设 2026/9/10 19:02:58

5分钟装好Arduino ESP32:从环境自检到离线部署

5分钟装好Arduino ESP32&#xff1a;从环境自检到离线部署 【免费下载链接】arduino-esp32 Arduino core for the ESP32 family of SoCs 项目地址: https://gitcode.com/GitHub_Trending/ar/arduino-esp32 Arduino ESP32的安装&#xff0c;本质是"安装&#xff0b;…

作者头像 李华
网站建设 2026/9/10 18:59:09

RPCS3汉化补丁保姆级教程:3分钟给PS3游戏装上中文

RPCS3汉化补丁保姆级教程&#xff1a;3分钟给PS3游戏装上中文 【免费下载链接】rpcs3 PlayStation 3 emulator and debugger 项目地址: https://gitcode.com/GitHub_Trending/rp/rpcs3 你是不是也这样&#xff1a;兴冲冲打开PS3游戏&#xff0c;结果满屏英文菜单、剧情选…

作者头像 李华
网站建设 2026/9/10 18:58:51

5分钟搞定:用TVBoxOSC把闲置电视盒子变成视频终端

5分钟搞定&#xff1a;用TVBoxOSC把闲置电视盒子变成视频终端 【免费下载链接】TVBoxOSC TVBoxOSC - 一个基于第三方项目的代码库&#xff0c;用于电视盒子的控制和管理。 项目地址: https://gitcode.com/GitHub_Trending/tv/TVBoxOSC 那台在电视柜角落吃灰的安卓盒子&a…

作者头像 李华
网站建设 2026/9/10 18:58:15

Mantine Spotlight 实战指南:为 React 应用打造 Overlay 命令中心

Mantine Spotlight 实战指南&#xff1a;为 React 应用打造 Overlay 命令中心 【免费下载链接】mantine A fully featured React components library 项目地址: https://gitcode.com/GitHub_Trending/ma/mantine Spotlight 是 Mantine 提供的全屏覆盖式命令中心组件&…

作者头像 李华
网站建设 2026/9/10 18:58:13

ζ函数:从素数分布到量子物理的数学桥梁

1. 从素数分布到量子物理&#xff1a;ζ函数的跨学科魅力第一次接触ζ函数是在研究素数分布问题时&#xff0c;这个看似简单的无穷级数定义背后&#xff0c;隐藏着数学中最深刻的奥秘之一。ζ函数最初由欧拉在18世纪系统研究&#xff0c;但直到黎曼将其扩展到复平面&#xff0c…

作者头像 李华