この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 18 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する(この記事)
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方
TL;DR
対象読者は、セキュリティ実装を「ライブラリを入れれば済む」と思っているエンジニアです。判断できないときにどちらへ倒すか という設計を実例で見ます。
- レート制限のキーは HMAC 化して、生 IP をログにも残しません
- 判断できないときは制限しない(フェイルオープン) と決め、その理由をコードに書いてあります
- ただし判定順を工夫して、「本番なのに設定が欠けている」ときだけは警告 を出しています
- 配線し忘れが実行時に見えないので、設計書の表と実コードを突き合わせる検査 を別に持っています
- セッション Cookie の復号は逆に 失敗したら必ず未ログイン扱い(フェイルクローズ)
秘密情報を読める場所を限定する
まず前提として、process.env を読んでいるファイルは 6 つだけ です。
grep -rln "process\.env" src/
-
src/infrastructure/platform/session-cookie.ts…SESSION_ENCRYPTION_KEY -
src/infrastructure/github/oauth.ts…GITHUB_OAUTH_* -
src/infrastructure/github/installation-token.ts…GITHUB_APP_* -
src/composition/rate-limit.ts…RATE_LIMIT_SALT -
src/composition/site-url.ts…SITE_URL - ほか 1 件
第 10 回の検査スクリプトが機械的に強制していて、この検査だけは抑止コメントで無効化できません。
さらに共通のパターンがあります。設定が揃っていなければ、機能を静かに無効化する。環境変数の値そのものを外へ返さず、真偽値だけ を返す関数になっています。値を返す関数を作ると、いつか誰かがログに出します。
レート制限で生 IP を残さない
/**
* ⚠️ **生 IP をログにも戻り値にも出さない**: `clientIpOf` が取り出した値は必ず `hashRateLimitKey` で
* HMAC 化してから `RateLimitPort.consume` へ渡す。生 IP をそのまま Cloudflare Rate Limiting binding の
* key や Workers Logs へ渡さない。
*/
IP アドレスは個人情報になりえます。レート制限には「同じ人からの連続アクセスか」が分かれば十分で、IP そのものを保持する必要はありません。
export async function hashRateLimitKey(source: string, salt: string): Promise<string> {
const encoder = new TextEncoder()
const cryptoKey = await crypto.subtle.importKey(
'raw',
encoder.encode(salt),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(source))
return Array.from(new Uint8Array(signature))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
node:crypto ではなく Web Crypto API を使っています。Cloudflare Workers に node:crypto が無いためで、「実行環境に無い API に依存しない」という選択です。
salt を使う理由 も押さえておきましょう。単なる SHA-256 だと、IPv4 は全部で 43 億通りしかないので 総当たりで元の IP を復元できます。秘密の salt を混ぜた HMAC なら、salt を知らない人には復元できません。
IP の取り方にも注意書きがあります。
x-forwarded-forは詐称可能なので、これ単独でレート制限を効かせてはならない
cf-connecting-ip(Cloudflare が付ける信頼できる値)を優先し、無ければ x-forwarded-for の先頭を使う。ただし後者は クライアントが自由に書ける ので、それ単独に頼らない、という但し書きです。
判断できないときは制限しない
async function enforceRateLimit(headers: Headers, keyPrefix: string): Promise<void> {
// 1. 接続元 IP を識別できない(Workers 実行環境の外・ヘッダ欠落等)場合は、
// そもそも誰を制限すべきか判定できないため間引かない。
const ip = clientIpOf(headers)
if (ip === null) {
return
}
// 2. binding 未提供環境(ローカル `npm test` / `next dev` / Cloudflare 実行環境の外)では
// そもそも Cloudflare Rate Limiting を呼びようがないため間引かない。
const binding = await rateLimiterBinding()
if (!binding) {
return
}
// 3. salt が未設定 / 空なら鍵を作れない。生 IP をキーにすると HMAC 化要件に反し、
// 固定文字列をキーにすると全利用者が 1 つのキーを共有して過剰に制限し合う。
// 🔴 ただし binding があるのに salt が無い = Workers 上での設定不備なので警告を残す。
const salt = process.env.RATE_LIMIT_SALT
if (!salt) {
warn('RATE_LIMIT_SALT 未設定のため間引きを無効化しています')
return
}
const key = `${keyPrefix}${await hashRateLimitKey(ip, salt)}`
// ... 実際の判定
}
判定できない 3 条件で、意図的に制限しません(フェイルオープン)。
セキュリティ機能は普通「判断できないなら止める」(フェイルクローズ)が原則です。ここで逆にした理由が、コメント 3 番目に書かれています。
salt なしで固定文字列をキーにすると 全利用者が 1 つのキーを共有して過剰に制限し合う
安全側に倒したつもりが、全ユーザーが 1 つの枠を奪い合う 状態になり、サービスが実質停止します。「安全側」がどちらかは、機能によって違うわけです。
そして超過時は、第 7 回で見た既存のエラー種別(rateLimitSecondary)を再利用します。新しいエラー種別を増やさない という判断です。
判定の順番が意味を持つ
この 3 条件は、当初 ① IP → ② salt → ③ binding の順でした。いまは ② と ③ が入れ替わっています。順番を変えても素通りする条件の集合は同じですが、区別できる異常の種類が変わります。
salt を先に見て早期 return すると、「Workers 上で salt だけが欠けた状態」(wrangler secret の付け替え・別環境への再デプロイで引き継がれなかった場合)が、「binding の無いローカル実行」と区別できません。どちらも同じ行で静かに抜けるからです。結果、本番でレート制限が全経路まとめて無効になっていても、例外も警告も出ません。
binding を先に取ると、3 番目に到達した時点で「Cloudflare の上で動いている」ことが確定します。だからそこだけは黙って通さず、console.warn を残せます。ローカル実行は 2 番目で先に抜けるので、テスト出力は従来どおり無音のままです。
「無音でよい環境」と「無音にしてはいけない環境」を、判定順だけで切り分けている。 フェイルオープンを選んでも、全部を黙らせる必要はありません。
経路ごとに枠を分ける
本体が共通関数 enforceRateLimit(headers, keyPrefix) になっているのは、レート制限をかける経路が 2 つに増えたからです。
export async function enforceSearchRateLimit(headers: Headers): Promise<void> {
await enforceRateLimit(headers, 'search:')
}
export async function enforceGemListRateLimit(headers: Headers): Promise<void> {
await enforceRateLimit(headers, 'gems:')
}
Cloudflare Rate Limiting のキー接頭辞が違えば、カウンタも別枠になります。なぜ分けるのか。守っているコストの種類が違う からです。
| 経路 | 守っているもの |
|---|---|
検索(画面 / GET /api/search) |
上流 GitHub API の枠 |
Gem 一覧(/{locale}/gems) |
自 Worker の CPU |
Gem 一覧は GitHub API を叩かないので、上流の 403 / 429 に間接的に守られることがありません。一方 limits.cpu_ms(第 19 回)は 1 リクエストあたりの CPU 時間の天井であって、リクエスト本数は制限しません。つまり本数を絞る仕組みが 1 つも無い経路でした。
そして枠を共有すると、「検索 → Gem 一覧」という主要導線で、正常な利用者どうしが枠を食い合います。だから接頭辞を分けます。
「同じ仕組みを使う」ことと「同じ枠を使う」ことは別 です。
書かれていないコードを見つける検査
第 10 回の検査は、書いたコードが規則を破っていないかを見ました。ここで見るのは逆で、書くべきコードが書かれていないこと を見つける検査です。
レート制限はフェイルオープンです。だから配線を忘れても、実行時には正常な状態と区別がつきません。例外も出ず、画面も普通に動きます。実際 /{locale}/gems は、新設時に配線が漏れたまま誰も気づきませんでした。
そこで tools/check_rate_limit_wiring.py が、適用経路の一覧(設計ドキュメントのマーカーで囲んだ表)と実コードを双方向に突き合わせます。「✅ 適用」と書いた経路に呼び出しが実在するか、「対象外」と書いた経路に呼び出しが紛れていないか、そして app/ 配下で URL を生む全ファイルを列挙して 表に載っていないものが無いか。
この検査の要点は、正本を自分で持たないこと です。docstring にこう書かれています。
本スクリプトは「何経路あるべきか」を自分では持たず、その表と実コードを突き合わせるだけ。
期待値をスクリプトに書き込むと、経路が増えたときにスクリプト自身も直す必要が出て、結局そこがドリフトします。
検出項目には、await の付け忘れも含まれます。await を書き忘れると例外が誰にも拾われないまま、レート制限は一切効きません。ESLint も型検査もこれを見ません。
枠の分離(search: / gems:)も検査対象です。表が宣言した接頭辞が実装に存在するか、✅ 行が呼んでいる関数の接頭辞と食い違っていないかまで見ます。設計上の「別枠にする」という判断が、文字列 1 つの typo で崩れないようにしてある わけです。
スクリプトは 831 行あります。「レート制限を呼んでいるか」を確かめるだけにしては大きい。それは、配線の有無ではなく配線の正しさ(順序・await・接頭辞・網羅)を見ているからです。
そして静的な突合だけでは足りません。第 13 回で見た app/[locale]/gems/page.test.tsx は、enforceGemListRateLimit が 検索より先に 1 回だけ呼ばれる ことを実際に呼び出して確かめています。表と実コードの突合と、実行して順序を確かめる assert。この 2 つが揃って初めて「忘れていない」と言えます。
セッション Cookie はフェイルクローズ
export async function encodeSessionCookie(payload: SessionPayload): Promise<string> {
const key = readKey()
if (!key) {
throw new Error('SESSION_ENCRYPTION_KEY が設定されていません')
}
return new EncryptJWT({ accessToken: payload.accessToken })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(`${SESSION_COOKIE_TTL_SECONDS}s`)
.encrypt(key)
}
GitHub のアクセストークンを JWE(暗号化した JWT) にして Cookie に入れます。署名ではなく 暗号化 なので、Cookie を見ても中身は読めません。
DB を持たない設計(第 3 回の ADR 0007)なので、セッションの保管場所が Cookie しかありません。だから中身を秘匿する必要があります。
復号側は、理由を問わず null(未ログイン扱い)へ倒します。鍵が違う、期限切れ、改ざん、形式不正 — どれも「未ログイン」です。
レート制限とは 逆方向 になります。
| 機能 | 判断できないとき | なぜ |
|---|---|---|
| レート制限 | 制限しない(オープン) | 閉じると全員が使えなくなる |
| セッション | 未ログイン扱い(クローズ) | 開くと他人のセッションを通しうる |
「フェイルオープンかフェイルクローズか」は機能ごとに考える。 どちらかを常に選ぶ話ではありません。
第三者の HTML を表示する
README の表示は、このアプリで最も危険な処理です。他人が書いた HTML を自分のページに埋め込む からです。
/**
* 変換内容(すべて 1 回の `sanitize-html` 呼び出しで完結させる):
* - 許可リスト方式でタグ・属性を絞る(`script` / `style` / `iframe` / `object` / `embed` /
* `form` / `input` と `on*` 属性、`javascript:` 等の危険スキームは確実に落とす)
* - 相対リンク・相対画像を絶対 URL へ解決する
* - 外部リンクに `target="_blank" rel="noopener noreferrer"` を付ける
* - 見出しを +2 シフトする(h1→h3 … h6 で cap)
* - テキスト長を変換パス内で累積し、上限を超えたら以降を切り詰める
*
* 🔴 `parseStyleAttributes: false` で postcss 経路自体を切る(style 属性を一切解釈しない)。
*/
許可リスト方式
「危険なタグを消す」(拒否リスト)ではなく「許可したタグだけ残す」(許可リスト)です。拒否リストは新しい攻撃手法が出るたびに漏れます。
SAFE_URL_SCHEMES = { http:, https: } として、javascript: はもちろん data: も除外 しています。data:text/html,... は HTML を直接埋め込めるためです。
1 回で終わらせる
「すべて 1 回の sanitize-html 呼び出しで完結させる」という制約が明記されています。
サニタイズ → 加工 → またサニタイズ、と重ねると、加工の途中に危険な状態が生まれる 可能性があります。特に「長すぎるので途中で切る」処理を後からやると、タグが閉じていない壊れた HTML ができます。
だから 切り詰めも変換パスの中で やります。常に整形式の HTML が保たれます。
見出しをずらす
README の # タイトル をそのまま出すと、ページ内に <h1> が 2 つできます。見出しの階層が壊れると、スクリーンリーダーで構造を辿れなくなります。だから h1 → h3 へずらす(h6 で頭打ち)。
セキュリティとアクセシビリティが同じ関数の中で処理されています。
オープンリダイレクト対策
/**
* ログイン/ログアウト完了後のリダイレクト先ホストを決める(オープンリダイレクト対策)。
* クライアント送信の `Host` ヘッダをそのまま信頼せず、`GITHUB_OAUTH_CALLBACK_URL` から
* 導出した許可ホストと一致する場合のみそれを使い、一致しない・比較不能な場合は
* 許可ホスト側へフォールバックする。
*/
export function resolveLandingHost(requestHost: string): string {
const allowed = callbackOrigin()
if (!allowed) return requestHost
const allowedHost = new URL(allowed).host
return requestHost === allowedHost ? requestHost : allowedHost
}
Host ヘッダは信用できません。 これを信じてリダイレクト先を決めると、攻撃者のサイトへ飛ばされます(オープンリダイレクト)。
対策は「環境変数から導出した許可ホストと一致するときだけ、リクエストの値を使う」。一致しなければ許可ホストへ倒します。
CSRF 対策
OAuth のログインでは state を使います。
- ログイン開始時に
crypto.randomUUID()で乱数を作り、httpOnly Cookie に入れる(第 4 回) - GitHub から戻ってきたとき、URL の
stateと Cookie の値を照合する
照合には タイミングセーフ比較 を自前で実装しています。crypto.timingSafeEqual は Node.js 専用で Workers に無いためです。
素朴な === は、先頭から比較して違いが見つかった時点で終わるので、一致した文字数が処理時間に現れます。それを手がかりに正解を推測されうる、という攻撃への対策です。
まとめ
- 秘密情報を読める場所を 機械的に限定 し、その検査には抜け道を作らない
- レート制限のキーは HMAC 化 して生 IP を残さない。salt を使うのは総当たり復元を防ぐため
- フェイルオープンかフェイルクローズかは機能ごとに判断する。レート制限は開き、セッションは閉じる
- 通す場合でも 判定順を工夫すれば「設定不備だけは警告する」 が作れる。経路が増えたら仕組みは共通化しても 枠は分ける
- フェイルオープンは配線し忘れと正常が実行時に区別できない。だから 設計書の表と実コードを双方向に突き合わせる検査 が要る
- 第三者の HTML は 許可リスト方式 で、1 回の変換で完結 させる
-
Hostヘッダのような クライアントが書ける値 は、許可リストと照合してから使う
シリーズの前後の記事
- ⬅️ 前の記事: 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
- ➡️ 次の記事: 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。