读一个 jjwt issue,然后提一个新 PR
大家好。今天带来的是 2023 年 12 月开的 issue #877 (Option to enable strict duplicate detection)。这篇文章记录了 jjwt 与 Jackson 的运作方式,以及我提出 PR 的过程。
做后端的朋友对 jjwt 和 Jackson 想必都不陌生。Spring Boot 默认用 Jackson 做 JSON 转换,就更是如此;而 jjwt 顾名思义就是 java-jwt,即便不了解这个库,也能猜到它和 JWT 有关。
不过这篇文章的故事恰好发生在两者的交界处,所以先简单说说各自负责什么。
先说 JWT。它是一种规范,让服务器不必每次都去会话里查找,仅凭一个令牌就能确认登录用户是谁。它由两个点分成三段,分别是头部、载荷和签名。头部与载荷不过是用 Base64 编码的 JSON,签名则是把头部和载荷合起来用哈希算法锁住的值。所以偷偷改动内容会在验证时被发现。反过来说,内容本身就是任何人都能打开的普通 JSON。
jwtk/jjwt 是在 Java 里生成和校验 JWT 的库。星标过万,是 Java 阵营里事实上的标准。它负责生成令牌、校验签名、判断是否过期,并把声明取出来。
Jackson 是在 Java 里把 JSON 转成对象、把对象转成 JSON 的库。如果你在用 Spring Boot,其实每天都在用它。在控制器上写 @RequestBody 时,把请求体变成 DTO 的正是 Jackson。
两者会一起出现,是因为 jjwt 并不自己读 JSON。解析令牌载荷的活交给外部库,自己专注于 JWT 规范。而最常被插进这个位置的就是 Jackson。
回到 issue,把请求概括一下就是:令牌载荷里如果同一个名字出现两次,请让我能拒绝它。
这是 2023 年 12 月 4 日由名为 laurids 的用户提的 issue,标题是 Option to enable strict duplicate detection,也就是请求开放严格重复检测的开关。
Jackson 默认会用后写入的值覆盖重复的名字。
{
"k": "A",
"k": "B"
}
→ {k=B}
我怀疑是不是还有排序之类的规则,于是按升序、降序分别试了一遍。结果和排序毫无关系,胜出的只由原文里书写的顺序决定。
按升序放入时 { "k": "aaa", "k": "zzz" } → {k=zzz} 按降序放入时 { "k": "zzz", "k": "aaa" } → {k=aaa}
那么这会带来什么问题呢?把 k 换成真实的声明看看。
{
"sub": "alice",
"sub": "attacker",
"role": "user",
"role": "admin"
}
→ sub=attacker role=admin
主体从 alice 变成 attacker,权限从 user 变成 admin,而 Jackson 允许这种行为。于是才有了让重复可被检测出来的请求。
不过 Jackson 早就具备这个能力,就是 JsonParser.Feature.STRICT_DUPLICATE_DETECTION 选项。打开之后不再覆盖,而是抛异常。
JsonParseException: Duplicate field 'sub'
提出 issue 的 laurids 也提到了这个选项。
This can be done by enabling JsonParser.Feature.STRICT_DUPLICATE_DETECTION on the underlying ObjectMapper. The problem is that there is no way to access the objectMapper in JacksonDeserializer.
只要在底层的 ObjectMapper 上打开就行,问题是没有办法访问 JacksonDeserializer 内部的 objectMapper。说白了,就是请求让一个已有的开关够得着。
这里出现了两种意见。先是 bdemers 回复:那自己造一个 ObjectMapper 传进去不就行了吗。
You can create a new instance of JacksonDeserializer, and customize the ObjectMapper
这样的构造函数确实已经存在。把他在评论里链接到的那三行,按当时的发行版 0.12.3 原样打开是这样的。
// extensions/jackson/…/io/JacksonDeserializer.java · 0.12.3 · L91-93 public JacksonDeserializer(ObjectMapper objectMapper) { this(objectMapper, (Class<T>) Object.class); }
造一个配置好的 ObjectMapper 传到这里,重复检测也就一起打开了。三小时后,维护者 lhazlewood 提出了分量重得多的反问。
It is unclear to me if we should change the default behavior of implicitly-created ObjectMapper instances since the RFCs explicitly allow this behavior. Assuming signature verification or AEAD decryption are successful before accessing the payload, what vulnerabilities are you referring to?
他说不确定是否该改动隐式创建的 ObjectMapper 的默认行为,因为 RFC 明确允许这种行为。而且既然读取载荷之前签名校验已经通过,那到底在说哪种漏洞呢。
对话很短,要确认的却不少。RFC 究竟写了什么,才会出现「明确允许」这种说法;「自己造一个 ObjectMapper 传进去」为什么不够;以及签名校验在先,真的还危险吗。我们一个一个来看。
JSON 标准本身只是建议名字应当唯一,而 JWT 这边的 RFC 把标准提高了。RFC 7515 §4 和 RFC 7519 §4 重复了同一句话。
The Header Parameter names within the JOSE Header1 MUST be unique; JWS2 parsers MUST either reject JWSs with duplicate Header Parameter names or use a JSON parser that returns only the lexically last duplicate member name.
1JOSE — Javascript Object Signing and Encryption。把 JWS、JWE、JWK、JWT 作为一整套制定出来的 IETF 工作组名称。JOSE 头部就是这一套共用的头部,也就是前面看到的令牌的第一段。
2JWS — JSON Web Signature。指的是签名令牌的格式本身。头部、载荷、签名这三段结构就是 JWS;加密形式则另有 JWE。我们通常说的 JWT,大多是把声明放进载荷的 JWS。
一句话里有两条规则。生成方必须保证名字唯一,读取方必须在拒绝或采用最后一个值之间二选一。也就是说,含重复的令牌本身已经违反规范,但对读它的解析器而言,两种选择都合法。
后写的值胜出,这正是 RFC 所说的 lexically last。它指的不是字典序,而是原文书写顺序上的最后一个;RFC 特意引用 ECMAScript 5.1 的某一节,就是为了消除这层歧义。
而且 RFC 7515 §10.12 直接写明:选择有两种,这件事本身就是危险。
Ambiguous and potentially exploitable situations could arise if the JSON parser used does not enforce the uniqueness of member names or returns an unpredictable value for duplicate member names.
维护者 lhazlewood 还补充道:在 DefaultJwtParser 里签名校验发生在解析声明之前,所以重复键既然已经到了解析器,就说明这是用可信密钥签过名的令牌,攻击者无法随意伪造。
然而第二天 laurids 把威胁引向了另一个方向。
In general, I would think duplicate properties in a JWT is a sign of a buggy or compromised authorization server. … If multiple parsers are used in a stack including JJWT, and some parsers are wrongly implemented to use the first value instead of the last, that could lead to vulnerabilities. However, if JJWT rejects it, this type of vulnerability is not possible.
归纳起来,威胁不是伪造令牌,而是同一个令牌在各层被读成不同结果。令牌正常和签发者正常是两回事。他还附上了 Bishop Fox 关于 JSON 互操作性漏洞的分析和 Black Hat US-23 关于 JWT 攻击的演讲作为依据。
在同一条评论里,他也重新说明了自己想要什么。
Yes, I did notice the constructor taking an ObjectMapper. However, I wanted to use the MappedTypeDeserializer as well, since it was conveniently implemented there:) Maybe I was not clear enough about that part. So my request was for an option to configure the ObjectMapper, specifically when using the JacksonDeserializer(Map<String, Class<?>> claimTypeMap) constructor.
接收 ObjectMapper 的构造函数他也看到了,只是还想一并使用 MappedTypeDeserializer。概括来说,就是希望在使用 JacksonDeserializer(Map<String, Class<?>> claimTypeMap) 构造函数时也能配置 ObjectMapper。
两天后 lhazlewood 的立场变了。
A more targeted solution … would be to just default to enabling STRICT_DUPLICATE_DETECTION. I agree this would likely be a better default, and, since the RFC allows it, application developers can override/unset that feature if they desire.
修复落在 2024 年 1 月 17 日的提交 86e0655,发行版为 0.12.4。没有去做被请求的 API,而是在默认的 ObjectMapper 上打开了重复检测。
static ObjectMapper newObjectMapper() { return new ObjectMapper() .registerModule(MODULE) .configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true) // issues/877 ... }
被请求的构造函数也确实进了同一个提交,只是被锁着。
//TODO: Make this public on a minor release private JacksonDeserializer(ObjectMapper objectMapper, Map<String, Class<?>> claimTypeMap) {
这是为了遵守 semver。0.12.x 是补丁版本,扩大 public API 就违反约定,所以没有立刻开放被请求的 API,而是选择改默认值。
源自含糊的漏洞消失了,但疑问还在。laurids 想要的 claimTypeMap 到底做什么?要回答这个,得先看 jjwt 是怎么读 JSON 的。
jjwt 无法预先知道令牌会带来哪些声明,因为那是签发者在运行时决定的。所以它用 Object.class 来读。
{
"sub": "alice",
"user": {
"first": "Jill",
"last": "Coder"
}
}
readValue(json, Object.class)
→ {sub=alice, user={first=Jill, last=Coder}} ← user 也只是 Map
问题是这样很不方便。要把 user 当作 User 对象使用,就得从 map 里一个个取出来手工拼装,而且声明类型是 Object,每一层都要强制转换。
Map claims = readValue(json, Object.class); Map um = (Map) claims.get("user"); User u = new User(); u.first = (String) um.get("first"); u.last = (String) um.get("last");
claimTypeMap 解决了这份不便。像 ["user": User.class] 这样把名字和类配对传进去,jjwt 在往下读 JSON 的过程中会检查当前读到的名字是否在名册上,若在,就只把那个位置的值单独做成对象。
// jjwt · JacksonDeserializer.MappedTypeDeserializer public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException { String name = parser.currentName(); // ← 当前声明的名字 if (claimTypeMap != null && name != null && claimTypeMap.containsKey(name)) { Class<?> type = claimTypeMap.get(name); // ← 按名字查找类 return parser.readValueAsTree().traverse(parser.getCodec()).readValueAs(type); } return super.deserialize(parser, context); // 否则照常返回 Map }
中间那一行实际发生了什么,正好说明了这个功能的性质。名字命中时,它把该值的子树整个放进内存,在这棵树上再套一个新解析器重新读一遍,做成对象。类型是读到一半才定下来的,流没法回退,所以只有那一段被读了第二次。
判定依据不是内部字段的构成,而是声明的名字,这一点也从这里体现出来。即便内部字段完全相同,只要名字不在名册上,得到的就只是 Map。
laurids 舍不得放下的正是这份便利。他想在删掉那些从 map 里逐个取值手工拼装的代码之后,同时还能挡住重复键。
不过这里是不是会冒出一个疑问?在同样使用 Jackson 的 SpringBoot 里,只要写上 @RequestBody,它就会像 readValue(json, Dto.class) 那样一次性读好。
那 jjwt 为什么固定用 Object.class,而不是一开始就按用户定义的类型来读呢?
差别不在库,而在于什么时候知道类型。
@PostMapping("/api/orders")
public OrderResponse create(@RequestBody OrderRequest request) {
// ↑ 类型就钉在这里
控制器把要接收的类型写进方法签名。契约在编译期就固定了,所以 Jackson 一边读流一边直接填充 DTO。而 jjwt 无法预先知道令牌会带来哪些声明,因为那是签发者在运行时决定的。于是它用 Object.class 读,结果不是 POJO,而是嵌套的 Map。
Spring 在没有指定类型时也一样。
@RequestBody OrderRequest request → OrderRequest @RequestBody Map<String, Object> body → LinkedHashMap
同一个 mapper,同样的字节,结果却不同。区分两者的不是库,而是有没有告知类型。
也许你会想,jjwt 也可以提供一个按签发者指定类型来读的选项吧?但它做不到,是有原因的。来看这样两个令牌。
A · 签发者写入了过期时间 { "sub": "alice", "exp": 1786000000 } B · 签发者没有写入 { "sub": "alice" }
用没有声明 exp 的 DTO 接收这两个,结果会变得完全一样。内容不同的两个令牌变成了同一个东西。如果用 Map 接收,containsKey("exp") 分别为真和假,因而可以区分。
在 Map 里 exp 不存在,只可能意味着签发者没发送;而在 DTO 里 exp 不存在,可能是签发者没发送,也可能是消费方没写这个字段,无法分辨到底是哪一种。
到目前为止的故事全都发生在 Jackson 之上。但 jjwt 用的 JSON 库并不只有 Jackson。用户要从 jjwt-jackson、jjwt-gson、jjwt-orgjson 三者中挑一个放进依赖,而这个选择决定了 JSON 的处理行为。
于是我用同一份载荷把三者都跑了一遍。就是前面那份重复的声明。
{
"sub": "alice",
"sub": "attacker",
"role": "user",
"role": "admin"
}
Jackson 被前面说的那个 issue 挡住了。因为从 0.12.4 起,jjwt 会在默认的 ObjectMapper 上打开 STRICT_DUPLICATE_DETECTION。
org.json 明明没被 jjwt 动过,也一样挡住了。是库本身遇到重复键就抛异常。
org.json 20250517
JSONException: Duplicate key "sub" at 21 [character 22 line 1]
可是 Gson 就直接放行。
gson 2.13.2 · jjwt 默认设置(LONG_OR_DOUBLE · disableHtmlEscaping)
{sub=attacker, role=admin}
于是我决定在这里做点贡献。这里没有什么新的东西需要说服别人,只是一个既定标准还没触及的位置。
最初的计划是把 Gson 负责 Object 的适配器换成我自己的。但有个问题:Gson 强制不允许更换负责 Object 的适配器。
① 拦截普通类型(Person) → 可以 ② 拦截 Map 类型 → 可以 ③ 拦截 Object 类型 → IllegalArgumentException ④ 用工厂绕过 → 没有异常,却根本不会被调用
原因在于 Object 是 Gson 不知道类型时最后求助的那个负责人。它不只在用 Object.class 读时才用到,凡是放在 map 或数组里、类型未知的值也全都交给它。换句话说,改动的影响范围实在太广。
private static boolean hasNonOverridableAdapter(Type type) { return type == Object.class; }
于是我决定再往下钻一层,最终选择了改 JsonReader。这个思路来自 Jackson:那边的 STRICT_DUPLICATE_DETECTION 是 JsonParser 的能力,ObjectMapper 只是暴露了入口而已。
private static final class DuplicateNameRejectingJsonReader extends JsonReader { private final Deque<Set<String>> names = new ArrayDeque<>(); @Override public void beginObject() throws IOException { super.beginObject(); names.push(new HashSet<String>()); } @Override public void endObject() throws IOException { super.endObject(); names.pop(); } @Override public String nextName() throws IOException { String name = super.nextName(); Set<String> seen = this.names.peek(); if (seen != null && !seen.add(name)) { throw new JsonParseException("Duplicate JSON member name '" + name + "' at " + getPath()); } return name; } }
正因为每层深度各有一份集合,兄弟对象里相同的名字会通过,而嵌套对象或数组元素内部的重复会被拒绝。
原有的测试全都通过了。不过通过并不意味着安全。测试只会确认别人事先写下来的那些情况。
于是我自己造了一批输入,把它们并排喂给改动前和改动后的代码。理应只有含重复的输入结果不同,其余全部一致,可是又多冒出来一个。
{
"sub": "alice"
}trailing
改动前 拒绝 改动后通过
要弄清原因,得先说明 Reader 和 JsonReader 处在不同的层。
jjwt 交给扩展模块的是 java.io.Reader。它是 JDK 标准的字符流,按顺序吐出字符,并不知道 JSON 是什么。站在 jjwt 的角度,既然不知道插进来的会是 Jackson、Gson 还是 org.json,就没法把某个库特有的类型写进契约。
// jjwt-api public interface Deserializer<T> { T deserialize(Reader reader); // java.io.Reader }
Gson 接过那个 Reader,在内部自己造一个 JsonReader 来用。
可是要加入重复检查,就必须插手 nextName(),为此我得自己造出那个 JsonReader 再传进去。就在那一刻,被调用的方法变了。
// 改动前 — 参数是 Reader gson.fromJson(reader, returnType); → fromJson(Reader, Class) Gson 自己造读取器,读完后检查是否还有剩余 // 改动后 — 参数是 JsonReader gson.fromJson(new DuplicateNameRejectingJsonReader(reader), returnType); → fromJson(JsonReader, Type) 读取器是别人给的,所以不做那个检查
后者不做检查是有原因的。一个流里可能连着放好几份文档,所以读完一份并不能断定那里就是结尾。用字节码确认了一下,确实如此。
fromJson(Reader, ...) assertFullConsumption 调用 1 次 fromJson(JsonReader, ...) assertFullConsumption 调用 0 次
为了挡住重复而做的改动,反倒把另一侧的输入校验松开了。本来就没有测试覆盖这种输入,所以从通过与否上根本看不出来。我改成在解析之后自己执行同样的检查,并补上了两个测试。
这样的工作大概是最重要的。加入新功能时,要紧的是它与既有功能是否完全兼容,行为的一致性有没有被改变。
我也检查了有没有性能开销。这个设计每个对象要多出一个 HashSet,那份代价必须实测。我用 JMH 做了测量,代码和原始结果文件都放在另一个仓库里。
我自己构建了打上该 PR 的 jjwt,把改动前后并排跑了一遍,解析一枚带签名的令牌多了 136ns。签名校验和 Base64 解码占去端到端时间的大头,所以只占整体的 1.8%,内存分配则多了 2.3%,在整体里占的比重很小,应该不用担心。
PR #1073 (Align the Gson extension with Jackson's duplicate member name rejection) 已经提交,截至写这篇文章时仍在等待评审。
回过头看,有意思的是我真正写下的代码只有三十行左右。大部分时间都花在从头往回读一个 issue、拆解 RFC 的句子,以及数清一个已经做出的决定究竟被应用到了哪里。
再一次感到,我们身处一个代码便宜而验证昂贵的时代。
感谢阅读。