前提: エッジが付与するヘッダーを信頼している

お問い合わせフォームのバックエンドは、Cloudflare を経由してオリジンへ届く構成です。エッジは実際の接続元IPを CF-Connecting-IP というヘッダーに入れて転送してくれます。

レート制限やログ記録のために、このヘッダーの値をクライアントの実IPとして使っていました。

function get_client_ip(): string
{
    $cf_ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
    if ($cf_ip !== '' && filter_var($cf_ip, FILTER_VALIDATE_IP)) {
        return $cf_ip;
    }
    return $_SERVER['REMOTE_ADDR'] ?? '';
}

コードには正しい注意書きがあった

このコードの直前には、次のコメントが書かれていました。

CF-Connecting-IP は .htaccess の Cloudflare IP 許可リストとセットで運用すること (許可リストが無いと直アクセス経由で自己申告ヘッダーとして偽装され得るため)。

懸念そのものは正確でした。 CF-Connecting-IP はHTTPヘッダーの一種で、リクエストを送る側が任意の値を設定できます。エッジを経由せずオリジンへ直接アクセスできてしまうと、誰でも好きなIPを名乗ってこのヘッダーを送り込めます。

レート制限は「同一IPからの短時間の大量送信」を検知する仕組みです。IPを自由に偽装できるなら、毎回違うIPを名乗るだけでレート制限は意味を失います。

セキュリティレビューで判明したこと

このコメントを読んだとき、当然「対応するIP許可リストがどこかにあるはず」と考えました。

ありませんでした。 .htaccess にCloudflareのIPレンジを許可リストとして書く実装は、一度も存在していませんでした。

つまり、次のような状態でした。

  • コード: 「エッジ経由のヘッダーを信頼する」
  • コメント: 「ただし許可リストとセットで運用すること」
  • 実態: 許可リスト無し

コメントに書かれた前提条件が満たされないまま、本体のコードだけが動き続けていました。注意書きは正しい認識を示していましたが、実装には反映されていませんでした。

なぜ気付きにくいか

このギャップは通常の利用では一切表面化しません。

  • 普段の利用者は必ずエッジを経由するので、CF-Connecting-IP は正しい値のまま届きます
  • レート制限も普通に機能しているように見えます(実際に機能します、エッジ経由である限りは)
  • エラーも警告も出ません

穴が使われるのは、オリジンへの直接アクセスを試みる相手がいたときだけです。平常時の動作確認では絶対に見つかりません。コメントを読んで「対応する実装があるはずだ」と裏を取らない限り、気付く機会がありませんでした。

是正: ヘッダーではなく接続元そのものを検証する

.htaccess 側で許可リストを作る代わりに、アプリケーション側でCloudflareの公式IPレンジとの照合を実装しました。

const CLOUDFLARE_IPV4_RANGES = [
    '173.245.48.0/20', '103.21.244.0/22', /* ... */
];
const CLOUDFLARE_IPV6_RANGES = [
    '2400:cb00::/32', '2606:4700::/32', /* ... */
];

function ip_in_cidr(string $ip, string $cidr): bool
{
    [$subnet, $prefixStr] = explode('/', $cidr);
    $prefix = (int) $prefixStr;
    $ipBin = @inet_pton($ip);
    $subnetBin = @inet_pton($subnet);
    if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
        return false;
    }
    $bytes = intdiv($prefix, 8);
    $bits = $prefix % 8;
    if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($subnetBin, 0, $bytes)) {
        return false;
    }
    if ($bits === 0) {
        return true;
    }
    $mask = chr((0xFF << (8 - $bits)) & 0xFF);
    return (($ipBin[$bytes] ?? "\x00") & $mask) === (($subnetBin[$bytes] ?? "\x00") & $mask);
}

function is_cloudflare_ip(string $ip): bool
{
    $ranges = str_contains($ip, ':') ? CLOUDFLARE_IPV6_RANGES : CLOUDFLARE_IPV4_RANGES;
    foreach ($ranges as $cidr) {
        if (ip_in_cidr($ip, $cidr)) {
            return true;
        }
    }
    return false;
}

そして get_client_ip() を、実際のTCP接続元がCloudflareのレンジ内にあるときだけヘッダーを信じる形に変えました。

function get_client_ip(): string
{
    $remote_addr = $_SERVER['REMOTE_ADDR'] ?? '';
    $cf_ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
    if ($remote_addr !== '' && is_cloudflare_ip($remote_addr)
        && $cf_ip !== '' && filter_var($cf_ip, FILTER_VALIDATE_IP)) {
        return $cf_ip;
    }
    return $remote_addr;
}

REMOTE_ADDR(TCP接続そのものの送信元)は、PHPの実行環境がOSレベルで確定させる値です。これはリクエストの送信側が偽装できません。ヘッダーの値を無条件に信じるのではなく、「本当にエッジから届いたリクエストか」を先に確認してから、ヘッダーの値を採用するという順序にしました。

エッジ以外から届いた場合は、たとえ CF-Connecting-IP が付いていても無視して REMOTE_ADDR をそのまま使います。直接アクセスしてきた相手は、直接アクセスしてきた相手として扱われます。

一般化: 「信頼の前提」と「信頼の担保」は別レイヤーにある

この件が典型的だったのは、認識の誤りではなく、実装の抜けだった点です。

コメントに書かれた懸念は正しく、対策の方針も正しく理解されていました。しかし方針を理解していることと、それを実装として存在させることは別の作業です。前者だけで満足すると、コメントという形で「対策済みのつもり」が固定化されます。

X-Forwarded-For や CF-Connecting-IP のような、エッジ・プロキシが付与する系のヘッダーは同じ構造の罠を抱えています。

  • ヘッダーの値そのものは、送信側が自由に設定できる
  • 信頼できるのは「エッジを確実に経由したこと」であって、ヘッダーの中身ではない
  • エッジを経由したことの担保は、接続元(TCP層)の検証でしか取れない

ヘッダーを使う設計をレビューするときは、「このヘッダーは誰が付与するか」だけでなく、「このヘッダーを付与しない経路が残っていないか」を確認する必要があります。今回は後者の確認が抜けていました。

まとめ

  • エッジが付与するヘッダーは、エッジを経由しない経路からは偽装できます。信頼するなら経由の担保が要ります
  • コメントに正しい注意書きがあっても、対応する実装が存在するとは限りません。書かれている前提は裏取りしてください
  • この種の穴はオリジンへの直接アクセスを試みる相手にしか使われないため、平常時の動作確認では見つかりません
  • 是正は、ヘッダーの値ではなく**実際の接続元(REMOTE_ADDR)**をまず検証し、信頼できる相手からのヘッダーだけを採用する形にすることです
  • プロキシ系ヘッダーをレビューするときは、「誰が付与するか」に加えて「付与しない経路が残っていないか」を確認してください