JWTの署名検証がない

認証用クレームを信頼する前にJWTの署名を検証する

説明

JWTの内容を解析することと、信頼できる署名を検証することは異なります。署名を検証せず認証用トークンを受け入れると、攻撃者がユーザーや権限のクレームを偽造できます。検証鍵を設定していても、署名のないトークンを受け入れる処理経路が残っていれば十分ではありません。

JJWTの動作はバージョンによって異なります。以下のJJWT 0.11.xの例では、汎用のparse()は署名のないJWTも処理できます。0.12以降は署名のないJWTを既定で拒否するため、汎用の解析呼び出しが常に署名検証を省略するとは限りません。

想定される影響

  • 偽造したクレームによるアカウントのなりすましや権限昇格
  • 信頼しない発行者や、別のサービス向けトークンの受け入れ
  • トークンを信頼して行うデータ参照・変更の不正実行

対処方法

  • 署名付きクレームを必須とするAPIを使ってください。JJWT 0.12以降ではJwts.parser().verifyWith(key).build().parseSignedClaims(token)を使い、署名のないトークンを許可しないでください。0.11.xの対応APIはparseClaimsJws()です。
  • 検証鍵と許可するアルゴリズムは信頼できる設定から選んでください。鍵の交換には確認済みの鍵一覧やkeyLocatorを使い、トークンが指定する任意のURLから鍵を取得しないでください。
  • 有効期限、発行者、対象サービス、必要なクレームを確認してください。署名検証だけですべての認可要件を満たすわけではありません。
  • JwtExceptionなどの検証失敗は認証拒否として扱い、失敗したトークンを単にデコードして受け入れ直さないでください。

例

JJWT 0.11.xの二つの解析経路を比較するSpringの抜粋です。HMACアルゴリズムに適した十分な長さのSecretKeyを、信頼できる構成から注入することを前提とします。実際のアプリケーションではクレームの方針と認証エラー応答も設定してください。

変更前

java
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;

@RestController
class TokenController {
    private final SecretKey key;

    TokenController(SecretKey key) {
        this.key = key;
    }

    @GetMapping("/me")
    public String me(@RequestHeader("Authorization") String authz) {
        if (!authz.startsWith("Bearer ")) {
            throw new JwtException("missing bearer token");
        }
        String token = authz.substring(7);
        // BAD: parse()は署名のないJWTも受け入れ得る
        Jwt<?, ?> jwt = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parse(token);
        Claims claims = (Claims) jwt.getBody(); // 署名が検証されていないクレームを使う可能性
        return claims.getSubject();
    }
}

変更後

java
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;

@RestController
class TokenControllerSafe {
    private final SecretKey key;

    TokenControllerSafe(SecretKey key) {
        this.key = key;
    }

    @GetMapping("/me")
    public String me(@RequestHeader("Authorization") String authz) {
        if (!authz.startsWith("Bearer ")) {
            throw new JwtException("missing bearer token");
        }
        String token = authz.substring(7);
        // GOOD: parseClaimsJws()で署名検証を必須にする
        Jws<Claims> jws = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);

        Claims claims = jws.getBody();
        // 追加の検証
        if (!"my-issuer".equals(claims.getIssuer())) {
            throw new JwtException("invalid iss");
        }
        return claims.getSubject();
    }
}

変更後はparseClaimsJws()で署名付きクレームを要求し、署名を検証してから発行者を確認します。my-issuerは実際に信頼する発行者へ置き換えてください。

参考資料