#238 (2026-08 レビューのまとめ Issue) をサブシステム別に分割したうちの workers 分。
項目番号 (w1〜w4) は #238 のトリアージコメントの採番。現存は 2026-08-24 / HEAD d61f126 で再確認済み。
packages/workers/src/checkpoint.ts は #226 / #227 とも重なる。着手前に両 Issue の in-flight を確認すること。
w2 — body サイズ上限が sign エンドポイントにしかない
packages/workers/src/index.ts:270 (verify-captcha) / :359 (verify-attestation) / :430 (session/start)
MAX_BODY_BYTES (8 KiB) は checkpoint.ts:65,148,168 = /api/checkpoint/sign だけ。
残る 3 エンドポイントの request.json() は無制限で、turnstileToken の長さ検査も無い
(Cloudflare の Turnstile 参考実装は > 2048 を拒否する)。
方針: readJsonBodyLimited() に切り出して 4 エンドポイントで共通化 + token ≤ 2048 + テスト。~30 行。
w1 — 署名鍵キャッシュが keyId のみをキーにする
packages/workers/src/checkpoint.ts:99-122
同一 keyId で JWK を差し替えると、isolate が生きている限り旧鍵で署名し続ける。
さらに補足として、registry (packages/shared/src/checkpointKeys/registry.ts:13-30) は
publicKeyJwk.x/y を持つので、同一 keyId で JWK を差し替えると registry の公開鍵と食い違い、
キャッシュの有無に関係なく署名が検証不能になる。つまり「ローテは必ず keyId 変更を伴う」は
運用ルールではなく不変条件。
方針 (推奨: 文書ではなくガード): importKey の前に JWK の x / y を registry と照合し、
不一致なら SIGNING_KEY_MISMATCH で fail-closed にする。~8 行 + テスト + packages/workers/CLAUDE.md に注記 3 行。
決定論的に強制でき、運用ミスをデプロイ直後の 500 で顕在化させられる。
w3 — catch がログなしの汎用 500
packages/workers/src/index.ts:326-341 (handleVerifyCaptcha) / :400-408 (handleVerifyAttestation)
#153 で session/start は揃えたが、この 2 つに残存 (後者は #238 本文未記載)。
JSON パース失敗も try の中なので 400 ではなく 500 になる。4-6 行。
w4 — API 応答にセキュリティヘッダが無い
X-Content-Type-Options: nosniff 等のヒットが 0。純 JSON API なので実害は小さい。
packages/workers/src/index.ts:546-573 の fetch() が全経路を包むので 1 箇所・~10 行で済む。
cors.test.ts にヘッダの assert を足す。
備考
- medium 相当: w2 (無制限 body)。low: w1 w3 w4
- ただし w1 はガードを入れる価値が高い (運用ミスが「署名は成功するが誰も検証できない proof」を生む)
Refs #238 #243
#238 (2026-08 レビューのまとめ Issue) をサブシステム別に分割したうちの workers 分。
項目番号 (w1〜w4) は #238 のトリアージコメントの採番。現存は 2026-08-24 / HEAD
d61f126で再確認済み。w2 — body サイズ上限が sign エンドポイントにしかない
packages/workers/src/index.ts:270(verify-captcha) /:359(verify-attestation) /:430(session/start)MAX_BODY_BYTES(8 KiB) はcheckpoint.ts:65,148,168=/api/checkpoint/signだけ。残る 3 エンドポイントの
request.json()は無制限で、turnstileTokenの長さ検査も無い(Cloudflare の Turnstile 参考実装は
> 2048を拒否する)。方針:
readJsonBodyLimited()に切り出して 4 エンドポイントで共通化 + token ≤ 2048 + テスト。~30 行。w1 — 署名鍵キャッシュが keyId のみをキーにする
packages/workers/src/checkpoint.ts:99-122同一 keyId で JWK を差し替えると、isolate が生きている限り旧鍵で署名し続ける。
さらに補足として、registry (
packages/shared/src/checkpointKeys/registry.ts:13-30) はpublicKeyJwk.x/yを持つので、同一 keyId で JWK を差し替えると registry の公開鍵と食い違い、キャッシュの有無に関係なく署名が検証不能になる。つまり「ローテは必ず keyId 変更を伴う」は
運用ルールではなく不変条件。
方針 (推奨: 文書ではなくガード):
importKeyの前に JWK のx/yを registry と照合し、不一致なら
SIGNING_KEY_MISMATCHで fail-closed にする。~8 行 + テスト +packages/workers/CLAUDE.mdに注記 3 行。決定論的に強制でき、運用ミスをデプロイ直後の 500 で顕在化させられる。
w3 — catch がログなしの汎用 500
packages/workers/src/index.ts:326-341(handleVerifyCaptcha) /:400-408(handleVerifyAttestation)#153 で
session/startは揃えたが、この 2 つに残存 (後者は #238 本文未記載)。JSON パース失敗も try の中なので 400 ではなく 500 になる。4-6 行。
w4 — API 応答にセキュリティヘッダが無い
X-Content-Type-Options: nosniff等のヒットが 0。純 JSON API なので実害は小さい。packages/workers/src/index.ts:546-573のfetch()が全経路を包むので 1 箇所・~10 行で済む。cors.test.tsにヘッダの assert を足す。備考
Refs #238 #243