この issue は 2023 年 12 月に始まり…[続きを読む]

jjwt の issue を読み、新しい PR を出すまで

イ・スンジェ · NHN AD
プラットフォームサービスラボ · 2026年8月 · 約12分

はじめに

こんにちは。今日は 2023 年 12 月に立てられた issue #877 (Option to enable strict duplicate detection) を一つ持ってきました。この記事は jjwt と Jackson の動作、そして PR を出すまでの記録です。

jjwt と jackson

バックエンドをやっている方には jjwt も Jackson もおなじみだと思います。Spring Boot が JSON 変換ライブラリとして Jackson を既定で使うのでなおさらですし、jjwt は名前のとおり java-jwt なので、このライブラリを知らなくても JWT に関わるものだと察しがつきます。

とはいえこの記事の話はちょうど両者の境目で起きるので、まずそれぞれが何をするのかを簡単に押さえておきます。

まず JWT です。ログイン中の利用者が誰なのかを、サーバーが毎回セッションを引かずにトークン一つで確認できるようにする規格です。見た目はドット二つで区切られた三つの断片で、それぞれヘッダー・ペイロード・署名です。ヘッダーとペイロードはただの JSON を Base64 で符号化したものにすぎず、署名はそのヘッダーとペイロードを合わせてハッシュアルゴリズムで固めた値です。ですから中身をこっそり書き換えれば検証で弾かれます。裏を返せば、中身そのものは誰でも開ける普通の JSONです。

eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhbGljZSJ9 . dBjftJeZ4CVP ヘッダー {"alg":"HS256"} 署名アルゴリズム Base64 で符号化された JSON ペイロード {"sub":"alice"} クレーム — 誰なのか、いつ失効するのか Base64 で符号化された JSON 署名 ヘッダー+ペイロードをハッシュで固めた値 中身を変えると検証で弾かれる 前の二つの断片はただの JSON — だから JSON パーサーが要る
署名は中身の改ざんを防ぐだけで、中身をどう読むかまでは決めてくれない。

jwtk/jjwt は、その JWT を Java で作り検証するライブラリです。スターが一万を超える、Java 界隈では事実上の標準として使われている OSS です。トークンを作り、署名を確かめ、失効していないかを見て、クレームを取り出す役目を担います。

Jackson は Java で JSON をオブジェクトに、オブジェクトを JSON に変換するライブラリです。Spring Boot をお使いなら、もう毎日使っています。コントローラーに @RequestBody を付けたときにリクエストボディを DTO にしてくれるのが Jackson です。

この二つが一緒に出てくる理由は、jjwt が JSON を自分で読まないからです。トークンのペイロードを解析する仕事は外部ライブラリに任せ、自分は JWT の規格に集中するというわけです。そしてその席にいちばんよく差し込まれるのが Jackson です。

では issue に戻って、要求をまとめるとこうなります。トークンのペイロードに同じ名前が二度入っていたら、それを拒否できるようにしてほしい。

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 を自分で作って渡せという答えではなぜ足りなかったのか、そして署名検証が先なのに本当に危ういのか。一つずつ見ていきます。

RFC を読み解く

JSON 標準そのものは名前が一意であるべきだと推奨するにとどまりますが、JWT 側の RFC はその水準を上げました。RFC 7515 §4RFC 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.

作る側 発行者 · 認可サーバー 名前は一意でなければならない · MUST 分岐は一つだけ 読む側 パーサー · ライブラリ 拒否する 最後の値を採る どちらも正当 RFC 7515 §10.12 — 曖昧で悪用されうる状況 同じトークンをどのパーサーで読むかで結果が分かれうるという意味
規格違反は発行側ですでに起きていて、その後をどう扱うかはパーサーごとに違いうる。

曖昧さから生まれる脆弱性

さらにメンテナの 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 攻撃の発表も添えています。

署名が有効なトークン一つ {"sub":"alice","sub":"attacker"} 署名検証はここで一度だけ行われる ゲートウェイ · ロギング パーサー A — 前の値を読む sub = alice 内部サービス · 認可 パーサー B — 後の値を読む sub = attacker 同じトークン 違う判定 どちらのコンポーネントも自分の基準では正常に動いている 監査ログには alice が残り、実際に動いたのは attacker
脅威は偽造ではなく解釈の不一致だ。署名検証はこの問題を防いでくれない。

そして同じコメントの中で、自分が何を望んでいたのかも改めて示しています。

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 をすぐ開ける代わりに、既定値を変える側を選んだわけです。

2023-12-04 #877 提起 12-04 · 12-06 反対 → 転換 根拠を受け取って二日で 2024-01-17 86e0655 · 0.12.4 既定値の変更 + コンストラクタの封印 2024-01-29 #914 開設 semver 待ち 2025-08 0.13.0 575 日後に公開 鍵のかかったコンストラクタが開くまで 575 日 CHANGELOG にはモジュール名なしで方針として書かれた ところが実際に適用されたのは三つの拡張のうち Jackson 一つだけだった
既定値を直すのに四週間、public API を一つ開けるのに 575 日かかった。

jjwt を読み解く

曖昧さから生まれる脆弱性は消えましたが、疑問はまだ残ります。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

問題は、これが不便だということです。userUser オブジェクトとして使うにはマップから一つずつ取り出して手で組み立てなければならず、宣言された型が 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
}

真ん中の一行で実際に何が起きているかが、この機能の性格を示しています。名前が引っかかると、その値のサブツリーをまるごとメモリに置き、その木の上に新しいパーサーを載せてもう一度読み、オブジェクトに仕立てます。読んでいる途中で型が決まるためストリームを巻き戻せず、その断片だけをもう一度読むわけです。

JSON を読み下ろしていて名前に出会う parser.currentName() = "user" claimTypeMap.containsKey(name) ない ある いつもどおり {first=Jill, last=Coder} readValueAsTree() サブツリーをメモリに置き traverse() その上に新しいパーサーを載せて readValueAs(User.class) 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

同じマッパーに同じバイト列を入れたのに結果が分かれます。両者を分けるのはライブラリではなく、型を伝えたかどうかです。

Spring · @RequestBody jjwt · トークンの解析 型は自分が決める create(@RequestBody OrderRequest r) 型は発行者が決める {"iss":…,"exp":…,"tenant":{…}} readValue(in, OrderRequest.class) readValue(in, Object.class) OrderRequest — キャストなしで使える Map — 取り出して使う 分けるのはライブラリではなく型を伝えたかどうかだ
同じ Jackson なのに結果が分かれる。Spring は契約がコンパイル時点で固定され、jjwt はそうできない。

jjwt も発行者に合わせて型を指定して読ませるオプションを用意すればよさそうに思えますが、そうできない理由があります。こんなトークンを二つ考えてみます。

A · 発行者が失効時刻を入れた
{
  "sub": "alice",
  "exp": 1786000000
}

B · 発行者が入れなかった
{
  "sub": "alice"
}

exp を宣言していない DTO でこの二つを受け取ると、結果がまったく同じになります。中身の違う二つのトークンが同じものになってしまうのです。Map で受け取れば containsKey("exp") がそれぞれ真と偽になるので区別できます。

Mapexp がないというのは発行者が送らなかったという意味だけですが、DTO で exp がないというのは発行者が送らなかったのか、利用側がフィールドを書かなかったのか、どちらなのか分かりません。

A · 発行者が失効時刻を入れた {"sub":"alice","exp":1786000000} B · 発行者が入れなかった {"sub":"alice"} DTO で受け取ると — exp を宣言していないクラス MyClaims(sub=alice) MyClaims(sub=alice) = 違うトークンが同じになった Map で受け取ると containsKey(exp) = true containsKey(exp) = false 「ない」の原因が一つだけなので区別できる
型を指定せずに読むのは怠慢ではなく契約だ。トークンに何が入っていたかを失わないための選択である。

統一されなかった動作

ここまでの話はすべて Jackson の上で起きていました。ところが jjwt が使う JSON ライブラリは Jackson だけではありません。jjwt-jacksonjjwt-gsonjjwt-orgjson の三つから一つを利用者が選んで入れることになっていて、その選択が JSON の処理動作を決めます。

jjwt-api + jjwt-impl JSON パーサーを持っていない ServiceLoader — 三つから一つを選んで依存に入れる jjwt-jackson JacksonDeserializer jjwt-gson GsonDeserializer jjwt-orgjson OrgJsonDeserializer Jackson ObjectMapper Gson org.json JSONObject どの拡張を選んだかが JSON の処理動作を決める
jjwt はアダプターだけを書き、実際の解析は外部ライブラリが行う。その選択は利用者に委ねられている。

そこで三つを同じペイロードで実際に走らせてみました。先ほどの重複したクレームです。

{
  "sub":  "alice",
  "sub":  "attacker",
  "role": "user",
  "role": "admin"
}

Jackson は先ほどの issue で塞がれています。0.12.4 から jjwt が既定の ObjectMapperSTRICT_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}
jjwt-jackson 拒否 jjwt が 2024 年に 明示的に有効にした jjwt-gson 通過 防御がまったくない Gson の既定動作そのまま jjwt-orgjson 拒否 ライブラリがたまたま塞いでいる 同じライブラリ、同じバージョン、同じトークンなのに判定が正反対だ ゲートウェイは jjwt-jackson、内部サービスは jjwt-gson という構成なら片側だけが破られる 拡張どうしは互いを知らないので、片方に掛けた防御は他方へ広がらない
2024 年に方針は宣言されたが、適用は一つのモジュールだけだった。残る二つはそれぞれ別の理由で危うい。

そこで、この部分に貢献してみることにしました。新たに説得すべきことは何もない、すでに宣言された基準がまだ届いていない場所ですから。

gson を読み解く

最初の計画は、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_DETECTIONJsonParser の機能で、ObjectMapper は入口を見せているだけだという点から着想を得ました。

Jackson Gson ObjectMapper スイッチを見せているだけ Gson 既定インスタンスに設定を埋め込む JsonDeserializer 型ごとの変換 — jjwt はここを使う TypeAdapter Object の席だけが施錠されている — 行き止まり JsonParser + DupDetector 深さごとに DupDetector が一つ 名前を読んだ瞬間に検査 JsonReader を継承 深さごとに Set が一つ 名前を読んだ瞬間に検査 違いは Jackson にはスイッチがあり Gson にはなかったので自分で作った、という点だけだ
重複検査は値を作る層ではなく、トークンを読む層の仕事だ。二つのライブラリともそうである。
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;
    }
}

深さごとに集合が別であるおかげで、兄弟オブジェクトの同じ名前は通し、入れ子のオブジェクトや配列要素の中の重複は拒否します。

読む順序 呼ばれるメソッド 深さ1 の記録簿 深さ2 の記録簿 { "x" : { "k": 1 }, "y" : { "k": 2 } } beginObject() nextName() beginObject() nextName() endObject() nextName() beginObject() nextName() endObject() endObject() [ ] 生成 [ x ] [ x, y ] 破棄 [ ] 生成 [ k ] 破棄 [ ] 新たに生成 [ k ] 破棄 記録簿は beginObject で作り endObject で捨てる。検査は nextName でのみ。

消えた火も もう一度見よう

もとからあったテストはすべて通りました。ただ、通ったということは安全だという意味ではありません。テストは誰かがあらかじめ書き留めた場合だけを確かめてくれるからです。

そこでいくつもの入力を自分で作り、変更前のコードと変更後のコードに並べて入れてみました。重複を含む入力だけ結果が分かれ、残りはすべて同じであるはずなのに、もう一つ引っかかりました。

{
  "sub": "alice"
}trailing

変更前 拒否     変更後は通過

原因を見るには、ReaderJsonReader が別の層だという点から押さえる必要があります。

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% の増加でしたが、全体に占める割合が小さいので心配するほどではなさそうです。

エンドツーエンド parseSignedClaims — 署名検証と Base64 デコードを含む 時間 7,562.7 ns +135.9 ns · 1.8% メモリ割り当て 48,736 B +1,136 B · 2.3% トークン一つの解析で時間 1.8%、メモリ割り当て 2.3% の増加
JMH · ウォームアップ 8 回 × 測定 8 回 × fork 6 回 × JDK 2 種。信頼区間は重ならない。

おわりに

PR #1073 (Align the Gson extension with Jackson's duplicate member name rejection) は提出済みで、この記事を書いている時点ではまだレビュー待ちです。

振り返って面白いのは、自分が実際に書いたコードが三十行ほどだという点です。時間の大半は、一つの issue を最初から遡って読み、RFC の文をほどき、すでに下された決定がどこまで適用されているかを数えることに費やされました。

コードは安く、検証は高くつく時代になったのだと改めて感じます。

お読みいただきありがとうございました。

参考