jjwt の issue を読み、新しい PR を出すまで
こんにちは。今日は 2023 年 12 月に立てられた issue #877 (Option to enable strict duplicate detection) を一つ持ってきました。この記事は jjwt と Jackson の動作、そして PR を出すまでの記録です。
バックエンドをやっている方には jjwt も Jackson もおなじみだと思います。Spring Boot が JSON 変換ライブラリとして Jackson を既定で使うのでなおさらですし、jjwt は名前のとおり java-jwt なので、このライブラリを知らなくても JWT に関わるものだと察しがつきます。
とはいえこの記事の話はちょうど両者の境目で起きるので、まずそれぞれが何をするのかを簡単に押さえておきます。
まず JWT です。ログイン中の利用者が誰なのかを、サーバーが毎回セッションを引かずにトークン一つで確認できるようにする規格です。見た目はドット二つで区切られた三つの断片で、それぞれヘッダー・ペイロード・署名です。ヘッダーとペイロードはただの JSON を Base64 で符号化したものにすぎず、署名はそのヘッダーとペイロードを合わせてハッシュアルゴリズムで固めた値です。ですから中身をこっそり書き換えれば検証で弾かれます。裏を返せば、中身そのものは誰でも開ける普通の JSONです。
jwtk/jjwt は、その JWT を Java で作り検証するライブラリです。スターが一万を超える、Java 界隈では事実上の標準として使われている OSS です。トークンを作り、署名を確かめ、失効していないかを見て、クレームを取り出す役目を担います。
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 です。
一文の中に規則が二つあります。作る側は名前を一意にしなければならず、読む側は拒否するか最後の値を採るかのどちらかを必ずしなければならない。つまり重複を含むトークンはそれ自体すでに規格違反ですが、それを読むパーサーにとっては二つの選択肢がどちらも正当なのです。
後に書かれた値が勝つという Jackson の動作こそ、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 オブジェクトとして使うにはマップから一つずつ取り出して手で組み立てなければならず、宣言された型が 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 が手放せなかったのは、この便利さでした。マップから値を一つずつ取り出して組み立てるコードを消したまま、重複キーまで防ぎたかったわけです。
ところで、ここで疑問が一つ浮かびませんか。同じ 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
同じマッパーに同じバイト列を入れたのに結果が分かれます。両者を分けるのはライブラリではなく、型を伝えたかどうかです。
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 担当アダプターを自分のものに差し替えることでした。ところが問題がありました。Object 担当アダプターは差し替えられないよう強制されているのです。
① 普通の型(Person)を横取り → できる ② Map 型を横取り → できる ③ Object 型を横取り → IllegalArgumentException ④ ファクトリで迂回 → 例外は出ないが呼ばれもしない
理由は、Object が Gson の型が分からないときに最後に頼る担当者だからです。Object.class で読むときだけ使われるのではなく、マップや配列の中にあって型の分からない値もすべてこの担当者に回ります。言い換えれば、変更の影響範囲が広すぎるということです。
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 の文をほどき、すでに下された決定がどこまで適用されているかを数えることに費やされました。
コードは安く、検証は高くつく時代になったのだと改めて感じます。
お読みいただきありがとうございました。