HonoでJWTのaudクレームを検証する機能がなかった問題(CVE-2025-62610)
今回、Honoに関する問題を報告して、脆弱性として認められたため大まかに情報をまとめてみました。
3行まとめ
-
HonoのJWT 認証ミドルウェアには
aud(Audience)クレームを検する機能がなかったため、脆弱性として報告しました - 上記の脆弱性により、他サービス向けトークンを自分の API が受け入れてしまうリスクがありました。
- 修正版は v4.10.2。
audを明示的に検証できるオプションを追加しました。(厳密にはデフォルトでするわけではないため、これで「直った」とは言い難いですが)
honoについて
Honoは、高速でシンプルなWebフレームワークであり、Bun、Deno、Node.js などの多様なランタイムで動作することや、今回問題となったJWTミドルウェアをはじめ、ビルトインのミドルウェアが豊富な点などが特徴です。
(Honoという名前はリポジトリのREADMEによると、炎🔥から来ているようです)
なぜ脆弱性と判断したか
端的には、JWTの検証における必須のステップがミドルウェア内に存在しなかったためです。
JWTの検証で一番わかりやすいものは、署名の検証ですが、JWTは署名が正しいだけでは不十分で、例えばaudクレームも検証しなければなりません(その他のクレームについては省略します)。
仕様(RFC 7519)には以下のようにあります。
If the principal processing the claim does not identify itself with a value in the "aud" claim when this claim is present, then the JWT MUST be rejected.
日本語に訳すると以下になります
"aud"クレームが存在する場合、クレームを処理する主体が"aud"クレームの値で自身を特定しない場合、JWTは拒否されなければなりません
もう少し噛み砕くと そのトークンが「どのサービス向けに発行されたか」 を示す aud クレームが、自サービス向けに払い出された値と一致しているかどうかを検証する必要があります。
問題の概要(何が起きていたか)
- advisory: https://github.com/honojs/hono/security/advisories/GHSA-m732-5p4w-x69g
- 対象: hono(影響があるバージョンはv1.1.0以降、修正バージョンはv4.10.2)
-
内容: HonoのJWTミドルウェアが デフォルトで
audを検証しないため、同じiss/同じ鍵を共有する複数サービス環境で、サービスBに向けて発行したトークンをサービスAが受け入れる問題が起こり得た。 - 区分: CWE-285(不適切な認可)
- スコア: CVSS 8.1 High
- デフォルトで検証をするようになっていないため、厳密には修正済みではない
起こり得る被害
単一のIdPを複数サービスで共有している構成では、iss は共通でも aud はサービスごとに異なるのが普通です。aud を見ない API は、別サービス宛てトークンを正規のものとして受け入れてしまい、意図しないサービス横断のアクセスを招きます。
例えば、Google Identityの場合、IDトークンのissの値は accounts.google.com または https://accounts.google.com とされており、RP(クライアント)に関わらず値が共通であることがわかります。また、audの値はクライアントIDとなっているため、audクレームの値を検証しない場合、他のサービス向けに発行されたJWTを受理してしまうことに繋がります
影響を受け得るケース
以下を同時に満たすケースでは影響を受ける可能性があります。
-
Hono v4.10.2 未満を使用し、JWT/JWK ミドルウェアに依存して
audを別途チェックしていない。 - マイクロサービス構成や単一IdP共有など、
issや鍵が共通の環境で、audがクライアントごとに異なっている。
安全な設定(4.10.2以降)
4.10.2でjwtメソッドの引数のverificationにaudが指定できるようになったので、string(またはstring[]かRegExp)でaudienceを渡してください(例えばGoogleのIDトークンならクライアントIDを指定する)。
注意:デフォルトでは検証対象のJWTにaudクレームが存在したとしてもエラーにはならないので、この設定をするまでは脆弱性の対応は完了していません。
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
const app = new Hono()
app.use('/api/*', jwt({
secret: process.env.JWT_SECRET!,
verification: {
aud: 'service-a', // ← このAPIが受け入れるaudienceを指定
iss: 'https://accounts.example.com',
},
}))
JWK/JWKS を使う場合
JWK ミドルウェア/verifyWithJwks 相当でもaud を必ず検証しましょう(外部 IdP 連携は特に重要)。
バージョンと公開情報(2025-10-22 時点)
- アドバイザリ公開: 2025-10-21
- 影響のあるバージョン: v1.1.0以降
-
修正版: v4.10.2(
aud検証のオプション追加) - CWE: 285(Improper Authorization)、CVSS: 8.1(High)
- CVE: CVE-2025-62610
その他: スクリプトで確認してみる
ChatGPTに、静的に今回の件の確認をする手段がないか聞いてみました。
以下のような完全ではないですがとりあえず動きはする、程度のスクリプトを吐いてくれました。
#!/usr/bin/env ts-node
import { Project, SyntaxKind } from 'ts-morph'
import * as path from 'node:path'
import * as fs from 'node:fs'
const SRC_DIR = process.env.AUD_GUARD_SRC ?? 'src'
const project = new Project({
skipAddingFilesFromTsConfig: true,
})
function addFilesRecursively(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name)
if (entry.isDirectory()) addFilesRecursively(p)
else if (/\.(t|j)sx?$/.test(entry.name)) project.addSourceFileAtPath(p)
}
}
addFilesRecursively(SRC_DIR)
type Violation = {
file: string
line: number
text: string
}
const violations: Violation[] = []
for (const sf of project.getSourceFiles()) {
const jwtImportNames = new Set(
sf.getImportDeclarations()
.filter((d) => d.getModuleSpecifierValue() === 'hono/jwt')
.flatMap((d) =>
d.getNamedImports().map((ni) => ni.getName())
)
)
if (!jwtImportNames.has('jwt')) continue
sf.forEachDescendant((node) => {
if (!node.isKind(SyntaxKind.CallExpression)) return
const ce = node
const expr = ce.getExpression()
if (
expr.getText() === 'jwt' &&
ce.getArguments().length > 0 &&
ce.getArguments()[0].getKind() === SyntaxKind.ObjectLiteralExpression
) {
const obj = ce.getArguments()[0].asKindOrThrow(
SyntaxKind.ObjectLiteralExpression
)
const confProp =
obj.getProperty('verification') ?? obj.getProperty('verifyOptions')
if (!confProp || !confProp.getFirstChildByKind(SyntaxKind.ObjectLiteralExpression)) {
const { line } = sf.getLineAndColumnAtPos(ce.getStart())
violations.push({
file: sf.getFilePath(),
line,
text: 'jwt() に verification/verifyOptions がありません',
})
return
}
const confObj = confProp
.getFirstChildByKindOrThrow(SyntaxKind.ObjectLiteralExpression)
const audProp = confObj.getProperty('aud')
if (!audProp) {
const { line } = sf.getLineAndColumnAtPos(ce.getStart())
violations.push({
file: sf.getFilePath(),
line,
text: 'jwt().verification/verifyOptions に aud がありません',
})
}
}
})
}
if (violations.length) {
console.error('❌ aud-guard: Hono jwt() で aud 未設定の箇所があります。')
for (const v of violations) {
console.error(`- ${v.file}:${v.line} ${v.text}`)
}
process.exit(1)
} else {
console.log('✅ aud-guard: すべての jwt() で aud が設定されています。')
}
これを
npm -i --save-dev ts-morph などしてからts-node で実行すると例えば以下のようになります。
❌ aud-guard: Hono jwt() で aud 未設定の箇所があります。
- ***/hono-aud-poc/src/server.ts:15 jwt().verification/verifyOptions に aud がありません
これを今回の件の対応の漏れがないかの確認に使うなりすると、aud検証のオプションを指定しているかどうかは(少なくとも即値で渡している限りは)確認できると思います。
Discussion