Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

X-Forwarded-Forの左端をレート制限キーにすると詐称で突破できる

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

X-Forwarded-Forの左端はクライアントが自由に書ける値なので、レート制限のキーに使うと毎回その値を変えるだけで、制限を一度も発動させずに突破できる。

結論

X-Forwarded-Forヘッダーの左端はクライアントが自由に書き込める値なので、それをレート制限のキーに使うと、毎回値を変えるだけで制限を一度も発動させずに突破できる。 教員共通パスワードのログイン試行回数制限(/api/auth/teacher-login)とprovisioningのIP制限は、どちらもこの左端をキーにしていたため機能していなかった。直し方は、Cloud RunのGFE(Google Front End)がヘッダーの末尾に実際の接続元IPを追記するという前提に乗って、右端から読む方式に変えることだった。

症状

2026-10-01のセキュリティ監査で見つかった。修正前のclientKeyFromHeadersは次のような実装だった。

// rate-limit.ts(修正前)
/**
 * Cloud Run / GFE は client IP を `x-forwarded-for` の左端に載せる。複数ホップは `, ` 区切りで
 * 連なるため先頭要素を採る。
 */
export function clientKeyFromHeaders(headers: Headers): string {
  const forwardedFor = headers.get("x-forwarded-for");
  if (forwardedFor) {
    const first = forwardedFor.split(",")[0]?.trim();
    if (first) return first;
  }
  const realIp = headers.get("x-real-ip")?.trim();
  if (realIp) return realIp;
  return "unknown";
}

このコードのdocstring自身が「左端XFFはclientが詐称しうる」とすでに書いており、リスクは認識されていた。だが実装はその左端を採用したままだった。攻撃者がx-forwarded-for: 1.2.3.4、次のリクエストでx-forwarded-for: 5.6.7.8と毎回違う値を送れば、clientKeyFromHeadersはその都度違うキーを返す。レート制限は「同じキーへの試行回数」を数える仕組みなので、キーが毎回変わる以上、1キーあたりの試行回数は常に1のまま制限閾値に達しない。教員共通パスワードへの総当たりが、回数制限という防御線を素通りできる状態だった。

原因

Cloud RunでGFE経由(ドメインマッピング直結)のリクエストを受けると、GFEはクライアントが送ってきたX-Forwarded-Forを消さずに、その末尾に実際の接続元IPを追記する。つまりヘッダーの値は<クライアントが書いた値(詐称可)>, <GFEが追記した実接続元>という形になり、信頼できる情報は右端にしかない。修正前のコードは「複数ホップは先頭要素」という一般的なXFFの説明に沿って左端を読んでいたが、この一般論は「クライアントから最初のプロキシまでの経路の先頭」を指すものであり、「どのセグメントが詐称不可能か」とは別の話だった。信頼境界の位置を取り違えたまま、左端という最も信頼できないセグメントをレート制限のキーに採用してしまっていた。

直し方

右端から、信頼するプロキシの段数だけ読み飛ばした値を採る方式に変えた。段数はXFF_TRUSTED_PROXY_HOPSというenv変数にして、将来External LBを前段に挟む場合に備えた。

// rate-limit.ts(修正後)
/**
 * XFF の右端から読み飛ばす「信頼するプロキシ」の段数(env `XFF_TRUSTED_PROXY_HOPS`、既定 0)。
 *
 * - **0(今の本番)**: Cloud Run ドメインマッピング直結。GFE が末尾に付ける接続元 = 右端。
 * - **1**: External HTTPS LB を挟んだ場合。XFF は `<client>, <LB の転送ルール IP>` になるので右から 2 番目。
 *   **LB(Cloud Armor)を入れるときは必ず 1 にする**。0 のままだと全員の key が LB の IP になり、教員ログイン
 *   10 回失敗で全校がロックされる。
 */
function trustedProxyHops(): number {
  const n = Number(process.env.XFF_TRUSTED_PROXY_HOPS ?? "0");
  return Number.isInteger(n) && n >= 0 && n <= 5 ? n : 0;
}

このコメントが示すとおり、段数のenv化は単に「将来LBを入れたときのため」ではなく、入れ忘れると全校ロックという別の事故になるという裏側の警告も兼ねている。右端読みに加えて、IPv6はレンタルサーバ等が/64単位でアドレスを丸ごと持つ点を踏まえ、/64にまとめてから比較する関数(rateLimitKeyForIp)も新設した。1アドレス単位で比較していると、IPv6アドレスを1つずつ変えるだけで同じく制限を無効化できたためだ。

テストは「クライアントが前置した偽のXFFは無視し、GFEが末尾に足した接続元を採る」ことを、2つの異なる詐称値が同じ実IPに正規化されることで確認している。

// rate-limit.test.ts
it("クライアントが前置した偽の XFF は無視し、GFE が末尾に足した接続元を採る(詐称で制限を外せない)", () => {
  const a = new Headers({ "x-forwarded-for": "1.2.3.4, 198.51.100.20" });
  const b = new Headers({ "x-forwarded-for": "5.6.7.8, 198.51.100.20" });
  expect(clientKeyFromHeaders(a)).toBe("198.51.100.20");
  expect(clientKeyFromHeaders(b)).toBe("198.51.100.20");
});

修正はレビューで一度訂正されている。最初に出した案は「Googleのプロキシ帯のIP範囲を右端から読み飛ばす」という発想だったが、これはIP帯のハードコードに依存し、インフラ構成が変わると追従できない。段数ベースのXFF_TRUSTED_PROXY_HOPSに変えたことで、インフラ構成(直結かLB経由か)が変わっても設定値ひとつで追従できるようにした。

再発防止

このモジュールのdocstringには、左端が詐称可能であることと、右端がGFE保証の値であることの両方が明記されている。加えてXFF_TRUSTED_PROXY_HOPSのコメントには「LBを入れるときに0のままだと全校ロックされる」という、設定を変え忘れたときの具体的な事故像まで書いてある。テストスイートには詐称・LB経由・空要素・IPv6ローテーションの4パターンが揃っており、将来誰かが「先頭要素を採る」実装に戻そうとしても、詐称テストがすぐに落ちる。

よくある質問

Q1X-Forwarded-Forの左端をレート制限キーに使うと何が起きますか?

左端はクライアントが自由に書き込める値なので、リクエストごとにランダムなIPを詐称して送れば、毎回「新しいクライアント」として扱われ試行回数制限が一度も発動しない。

Q2なぜ右端から読めば直るのですか?

Cloud RunのGFE(Google Front End)は、クライアントが送ってきたX-Forwarded-Forを書き換えずに、その末尾へ実際の接続元IPを追記する。クライアントは末尾に新しい値を追加できないため、右端はGFEが保証した値になる。

Q3External LBを前段に挟んだら右端読みは壊れませんか?

壊れる。LBがもう1段ホップを足すため、右端はLBのIPになってしまう。信頼するプロキシの段数をXFF_TRUSTED_PROXY_HOPS(既定0)というenv変数にして、LB導入時に1へ上げる前提にしている。

確認した環境

  • Next.js App Router(apps/web)/ Cloud Run デプロイ・ドメインマッピング直結(GFE経由)
  • 2026-10-01 セキュリティ監査で発覚、同日 commit 1636418d で修正

この記事の根拠

  • TypeScriptファイル 99〜139行目コミット 1636418
  • TypeScriptファイル 113〜158行目コミット 1636418

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。