Server & WordPress

レート制限入門:フォームへのDoS攻撃を防ぐ

· 4 min read

多くの環境では、保護されていないContact Form 7のエンドポイントは、秒間数件〜数十件規模の連続送信でリソース競合が顕在化し始めます。一見無害に聞こえますが、ボットオペレーターがスクリプトでそのエンドポイントを狙えば、単一のスクリプトでも1時間に数万件規模のリクエストを発生させることは難しくありません。各リクエストはWordPressのアプリケーションスタックを通過し、プラグインフックをトリガーし、データベースに書き込み、メール送信処理(SMTPや外部メールサービス連携を含む)をトリガーします。サーバーの処理能力次第では、数分程度でPHP-FPMプールが上限(max_children)に達しリクエスト待ちが発生し、MySQLは書き込み負荷の増大によりI/O待ちやロック競合が発生しクエリ処理が滞留し、本物の訪問者は502エラーを眺めることになりかねません。

これは高度な攻撃者による分散型DDoS攻撃ではありません。フォームエンドポイントに対してwhile trueループを実行している1台のマシンです。そしてその対策であるレート制限は、システムエンジニアのツールキットにおける最も古く、最も効果的なツールの一つです。しかし、ほとんどのWordPressインストールではまったく実装されていません。

この記事では、レート制限とは何か、スロットリングの仕組み、IPごとの制限がなぜ必要だが不十分なのか、そして2026年にリクエスト制限を実装する際にIPv6アドレッシングが生み出す特有の課題を解説します。

問題:あなたのフォームは制限なしのAPIエンドポイントである

インターネット上のすべてのコンタクトフォームは、アーキテクチャ上、パブリックなAPIエンドポイントです。任意のIPアドレスからのPOSTリクエストを受け付け、アプリケーションスタックで処理し、レスポンスを返します。一般的なREST APIとは異なり、認証、クォータ管理、リクエスト制限がほぼ皆無です。

考えてみてください。もし、世界中の任意のクライアントから無制限の非認証リクエストを受け付けるSaaS APIを構築したら、セキュリティチームが昼食前にそれを停止させるでしょう。しかし、それが標準的なWordPressコンタクトフォームの実態です:入場制限のない公開エンドポイントです。

その結果は予測可能です:

  • リソース枯渇。 各フォーム送信はCPU、メモリ、データベースI/O、そしてしばしばネットワークI/O(SMTP)を消費します。十分な量になると、正当なリクエストの処理に支障をきたします。
  • メール評判の損傷。 短時間に数千の通知メールが送信されると、SpamhausやBarracudaなどのブラックリストに登録されるリスクが高まります。
  • データベース肥大化。 送信をログに記録するプラグインはwp_postswp_postmetaに行を書き込みます。1日に1万件のジャンク送信で、数万〜数十万規模のメタ行が追加される可能性があります。
  • 連鎖的な障害。 上限に達したPHP-FPMプールはフォームだけでなく、サイト全体の応答不能を引き起こす可能性があります。

レート制限は最低限の防御です。完全なソリューションではありませんが、他のすべてが構築される基盤です。

レート制限は攻撃を止める仕組みではない。システムが壊れる速度を制御する仕組みである。

技術詳解:レート制限の仕組み

基本概念

レート制限は、クライアントが指定された時間ウィンドウ内にエンドポイントに対して行えるリクエスト数を制限します。クライアントが制限を超えると、後続のリクエストは拒否されます――通常はHTTP 429 Too Many Requests レスポンスで――ウィンドウがリセットされるまで。

概念はシンプルです。実装の詳細が複雑になるところです。

スロットリングとハードリミット

これらの用語は互換的に使われますが、異なる動作を表します:

ハードレート制限は二値的です。ウィンドウあたりN件のリクエストが許可されます。N+1件目は即座に拒否されます。クライアントは429を受け取り、ウィンドウがリセットされるまで待つ必要があります。

スロットリングは段階的です。ハード拒否の代わりに、サーバーがレスポンスを遅延させるか、リクエストをキューに入れます。クライアントは引き続きサービスを受けられますが、処理速度が低下します。完全に遮断するのではなく、段階的に劣化させたい場合に有用です。

実際には、ほとんどの実装は両方を組み合わせます:まずスロットリング、それでもクライアントが続ける場合はハードリミットを適用。

クライアントがリクエスト#1を送信  -> 200 OK(即時)
クライアントがリクエスト#2を送信  -> 200 OK(即時)
クライアントがリクエスト#3を送信  -> 200 OK(即時)
クライアントがリクエスト#4を送信  -> 200 OK(500ms遅延 — スロットリング)
クライアントがリクエスト#5を送信  -> 200 OK(2000ms遅延 — さらに強いスロットリング)
クライアントがリクエスト#6を送信  -> 429 Too Many Requests(ハードリミット)

この段階的なアプローチは、送信ボタンを誤ってダブルクリックした正当なユーザーに対してはフレンドリーでありながら、接続が許す限りの速度でリクエストを送信する自動化スクリプトに対しては攻撃的に機能します。

アルゴリズム:固定ウィンドウ、スライディングウィンドウ、Leaky Bucket、Token Bucket

リクエストのカウントと制限の適用にはいくつかの標準的なアプローチがあります。それぞれにトレードオフがあります。

固定ウィンドウ

時間を固定間隔(例えば60秒のウィンドウ)に分割します。各ウィンドウでクライアントごとのリクエスト数をカウントします。ウィンドウが期限切れになるとカウンターをリセットします。

ウィンドウ: 12:00:00 - 12:00:59  |  リクエスト: 5/5(上限到達)
ウィンドウ: 12:01:00 - 12:01:59  |  リクエスト: 0/5(カウンターリセット)

問題点: 境界バースト。クライアントが12:00:58に5件、12:01:01にさらに5件のリクエストを送信でき、技術的には「毎分5件」の制限を超えていないにもかかわらず、3秒間で実質10件のリクエストを処理させることができます。

スライディングウィンドウ

固定間隔の代わりに、ウィンドウが各リクエストとともにスライドします。サーバーは「このクライアントは過去60秒間に何件のリクエストを送信したか?」をチェックします。これにより境界バーストの悪用が排除されます。

# 疑似コード: スライディングウィンドウレートリミッター
def is_allowed(client_id, max_requests=5, window_seconds=60):
    now = time.time()
    # ウィンドウより古いタイムスタンプを削除
    requests[client_id] = [
        t for t in requests[client_id] if t > now - window_seconds
    ]
    if len(requests[client_id]) >= max_requests:
        return False  # レート制限
    requests[client_id].append(now)
    return True

スライディングウィンドウはより正確ですが、個々のリクエストのタイムスタンプを保存する必要があり、クライアントあたりのメモリ使用量が増えます。

Leaky Bucket(漏れバケツ)

これはNginxのlimit_reqモジュールが採用しているアルゴリズムです。

底に穴が開いたバケツを想像してください。リクエストはバケツに水を注ぎ込みます。水は一定のレート(設定した処理速度)で底から漏れ出します。バケツが満杯になると、新しい水(リクエスト)は溢れて拒否されます。

Leaky Bucketの特徴: リクエストがバースト的に到着しても、処理は常に一定のレートで行われます。burstパラメータがバケツの容量に相当し、一定数のリクエストをキューに入れて遅延処理できます。nodelayオプションを付けると、バースト分をキューで待たせずに即時処理しますが、バケツの残量は消費されます。

# Nginx limit_reqの動作(Leaky Bucket)
rate=5r/m (12秒ごとに1リクエスト処理) + burst=2

リクエスト#1  -> 即時処理(バケツ残量: 2)
リクエスト#2  -> 即時処理(バケツ残量: 1)— burstを消費
リクエスト#3  -> 即時処理(バケツ残量: 0)— burstを消費
リクエスト#4  -> 429 拒否(バケツ満杯、12秒後に1枠回復)

Token Bucket

Token Bucketは多くのAPI基盤(AWS API Gateway、Redis系のレートリミッターなど)で広く使われるアルゴリズムです。

一定数のトークンを保持するバケツを想像してください。各リクエストは1つのトークンを消費します。トークンは一定のレートで補充されます(例:毎分5件の制限なら12秒ごとに1トークン)。バケツが空の場合、リクエストは拒否されます。

# 疑似コード: Token Bucketレートリミッター
class TokenBucket:
    def __init__(self, capacity=5, refill_rate=5/60):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_rate = refill_rate  # 秒あたりのトークン数
        self.last_refill = time.time()

    def allow_request(self):
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(
            self.capacity,
            self.tokens + elapsed * self.refill_rate
        )
        self.last_refill = now

        if self.tokens >= 1:
            self.tokens -= 1
            return True  # リクエスト許可
        return False  # レート制限

Token BucketとLeaky Bucketの違い: Token Bucketはバースト送信を即時処理できます(トークンが残っている限り)。Leaky Bucketは出力レートを一定に保ちます。Nginxのlimit_reqはLeaky Bucketを採用していますが、burst+nodelayオプションの組み合わせにより、Token Bucketに近い挙動を実現することも可能です。どちらもクライアントごとに少量の状態(カウンターとタイムスタンプ)だけで済むため、大規模環境でもメモリ効率が良好です。

IPごとの制限が必要な理由

自明な疑問:クライアント識別子として何を使うべきか?

コンタクトフォームのような非認証エンドポイントでは、クライアント識別の初期キーとして、IPアドレスが依然として広く利用されます。Cookie、フィンガープリント、トークンといった他の識別手段も存在しますが、いずれもクライアントサイドの状態に依存するため、レート制限の第一キーとしてはIPアドレスが最も実用的です。

IPごとのレート制限が機能する理由:

  1. 自動化スクリプトは単一のオリジンから実行される。 大半の低〜中規模のボット攻撃は、1台のサーバーまたは少数のVPSインスタンスから発信されます。IPあたり毎分5件の制限で実効的に抑制できます。
  2. 共有リソースを保護する。 分散型攻撃を止められなくても、IPごとの制限により、単一のクライアントがPHPワーカーを独占することを防ぎます。
  3. 正当なユーザーにはコストがかからない。 正当な人間がコンタクトフォームを1分間に2回以上送信することはまずありません。毎分5件の制限は、本物の訪問者にとって見えません。

Webサーバー層での実装は簡単です:

# Nginx: フォームエンドポイント用のLeaky Bucketレートリミッター
# ゾーンは10MBの共有メモリを割り当てる(約160,000のIPv4アドレス分)
limit_req_zone $binary_remote_addr zone=formsubmit:10m rate=5r/m;

server {
    location ~* /wp-json/contact-form-7/ {
        limit_req zone=formsubmit burst=2 nodelay;
        limit_req_status 429;

        # レート制限を通過した場合のみPHP-FPMに渡す
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }
}

または、PHPでのアプリケーション層での実装:

// WordPress: transientを使用したアプリケーションレベルのレートリミッター
function check_form_rate_limit(): bool {
    $ip = $_SERVER['REMOTE_ADDR'];
    $key = 'rate_limit_' . md5($ip);
    $window = 60; // 秒
    $max_requests = 5;

    $data = get_transient($key);

    if ($data === false) {
        set_transient($key, ['count' => 1, 'start' => time()], $window);
        return true;
    }

    if ($data['count'] >= $max_requests) {
        return false; // レート制限
    }

    $data['count']++;
    set_transient($key, $data, $window - (time() - $data['start']));
    return true;
}

どちらのアプローチも機能します。Nginxのアプローチの方が優れています。PHPに到達する前にリクエストを拒否するため、スタックの最もコストの高い部分を節約できます。PHPのアプローチは、Webサーバーを直接設定できない環境(共有ホスティング、マネージドWordPressプラットフォーム)でのフォールバックです。

IPv6の問題:IPごとの制限が機能しなくなる理由

ここから難しくなります。

前のセクションのすべてはIPv4を前提としていました。1つのIPアドレスがおおよそ1つのクライアント(または少なくとも1つのNATゲートウェイ)に対応する世界です。IPv6はその計算を根本的に変え、ほとんどのレート制限実装は追いついていません。

IPv6アドレス空間のスケール

ISPが家庭に割り当てるIPv6プレフィックスは、一般的に/48から/64の範囲です(ISPによって異なり、/56の割り当ても多く見られます)。仮に/48割り当てであれば、2^80個のアドレス――事実上無限のIP――を持ちます。控えめな/64割り当て(単一サブネット)でも2^64アドレス、つまり約1,844京(1.8×10^19)を提供します。

単一の/64 IPv6ブロックを持つボットオペレーターは、すべてのリクエストにユニークな送信元アドレスを割り当て、一度も再利用しないことが可能です。IPごとのレートリミッターは各リクエストを新しいクライアントとして認識し、制限は一度もトリガーされません。

リクエスト1:    2001:db8:1234:5678::1 から         -> カウンター: 1/5
リクエスト2:    2001:db8:1234:5678::2 から         -> カウンター: 1/5
リクエスト3:    2001:db8:1234:5678::3 から         -> カウンター: 1/5
...
リクエスト100万: 2001:db8:1234:5678::f4240 から    -> カウンター: 1/5

100万リクエスト。レート制限のトリガーはゼロ。すべてが「異なる」IPからです。

対策:個別IPではなくCIDRブロックでレート制限する

解決策は、IPv6アドレスを個別に扱うのをやめて、プレフィックスで集約することです。/64ブロック内のすべてのアドレスは通常1つのエンティティによって制御されるため、レートリミッターはそれに応じてグループ化すべきです。

完全な128ビットアドレスではなく、/64プレフィックスをキーにします:

import ipaddress

def get_rate_limit_key(ip_string: str) -> str:
    addr = ipaddress.ip_address(ip_string)

    if isinstance(addr, ipaddress.IPv6Address):
        # /64にマスク — このブロック内のすべてのアドレスが1つのカウンターを共有
        network = ipaddress.IPv6Network(f"{addr}/64", strict=False)
        return str(network.network_address)
    else:
        # IPv4: 個別アドレスを使用
        return str(addr)

# 例:
get_rate_limit_key("2001:db8:1234:5678::1")
# -> "2001:db8:1234:5678::"

get_rate_limit_key("2001:db8:1234:5678::ffff:9999:abcd")
# -> "2001:db8:1234:5678::"

get_rate_limit_key("203.0.113.42")
# -> "203.0.113.42"

上記のIPv6の例はどちらも同じキーにマッピングされます。これでレートリミッターがまとめてカウントするようになり、同じ割り当てからのものなので、正しい動作です。

NginxでCIDR対応のレート制限を実装する

NginxはネイティブではプレフィックスベースのIPv6レート制限をサポートしていません。mapまたはLuaを使用して/64プレフィックスを抽出する必要があります:

# IPv6から/64プレフィックスを抽出し、IPv4はそのまま通す
map $remote_addr $rate_limit_key {
    "~^(?P<prefix>[0-9a-f:]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+):.*$"  $prefix;
    default  $remote_addr;
}

limit_req_zone $rate_limit_key zone=formsubmit:20m rate=5r/m;

より精密な制御には、OpenResty(Nginx + Lua)でプログラマティックなアクセスが可能です:

-- OpenResty: CIDR対応のレート制限
local function get_ipv6_prefix_64(addr)
    -- IPv6アドレスの最初の4グループ(64ビット)を抽出
    local groups = {}
    for group in addr:gmatch("([0-9a-fA-F]+)") do
        table.insert(groups, group)
        if #groups == 4 then break end
    end
    return table.concat(groups, ":") .. "::"
end

local client_ip = ngx.var.remote_addr
local key

if client_ip:find(":") then
    key = get_ipv6_prefix_64(client_ip)
else
    key = client_ip
end

/64の前提が常に正しいとは限らない

これは多くの人がつまずくポイントです。/64はRFC 4291で定義された標準的なサブネットサイズですが、すべてのIPv6割り当てがルールに従っているわけではありません:

  • クラウドプロバイダーは個々のVMに/128アドレス(単一IP)を割り当てることがあります。この場合、/64でレート制限すると、無関係な顧客をグループ化してしまいます。
  • 大企業は内部で/48割り当てを使用することがあり、最初の48ビットが組織を、49〜64ビットが内部サブネットを識別します。
  • モバイルキャリアは共有の/48プールから/128を割り当てることがあり、完全に無関係なユーザーが/64プレフィックスを共有する場合があります。

完璧なプレフィックス長は存在しません。/64が実用的なデフォルトです。IPv6ローテーション攻撃の大部分を捕捉しつつ、共有インフラでの誤検出を最小限に抑えます。ただし、データがあれば適応型のプレフィックス長を検討してください:/128から開始し、ローテーションが確認されたら/64に拡大し、同じ/48ブロック内でローテーションが続く場合は/48に拡大します。

# 適応型CIDRベースのレート制限(概念的)
def adaptive_rate_limit(ip: str, store: dict) -> bool:
    prefixes_to_check = [
        ("/128", ipaddress.IPv6Network(f"{ip}/128", strict=False)),
        ("/64",  ipaddress.IPv6Network(f"{ip}/64",  strict=False)),
        ("/48",  ipaddress.IPv6Network(f"{ip}/48",  strict=False)),
    ]

    for label, network in prefixes_to_check:
        key = str(network.network_address) + label
        count = store.get(key, 0)

        if label == "/128" and count > MAX_PER_IP:
            return False  # 単一IPが上限超過
        if label == "/64" and count > MAX_PER_64:
            return False  # /64ブロックが集約上限超過
        if label == "/48" and count > MAX_PER_48:
            return False  # /48ブロックが集約上限超過

        store[key] = count + 1

    return True

これはより複雑ですが、はるかに耐性があります。複数のズームレベルで同時に不正使用を監視しているのです。

解決策:フォームレート制限の多層アプローチ

レート制限だけではフォームの悪用は解決しません。住宅用プロキシネットワークにアクセスできるボットオペレーターは、数千のユニークな/64ブロックにリクエストを分散させ、各ブロックから1〜2件の送信にとどめることができます。これは妥当なレート制限をはるかに下回ります。しかし、レート制限は他のすべての防御をより効果的にする基盤です。

実践で機能するアーキテクチャは以下の通りです:

レイヤー1:Webサーバーのレート制限(Nginx / Apache)

最も粗いフィルターです。大量の不正使用をPHPに到達する前に拒否します。上述のCIDR対応の制限を設定してください。正当なユーザーが通常ヒットしない程度に寛容な閾値を設定します。/64あたり毎分5〜10件の送信が安全な出発点です。

コスト: ほぼゼロ。Nginxはこれを共有メモリで処理し、ディスクI/Oは発生しません。

レイヤー2:アプリケーション層のスロットリング(WordPress / PHP)

Webサーバーフィルターを通過したリクエストに対して、アプリケーション内で二次チェックを追加します。このレイヤーはより賢くなれます。フォームのメタデータ、セッション状態、送信内容にアクセスできるためです。

// 段階的レスポンスによるアプリケーション層スロットリング
function throttle_form_submission(string $ip): string {
    $key = 'throttle_' . get_rate_limit_key($ip);
    $count = (int) get_transient($key);

    if ($count === 0) {
        set_transient($key, 1, 300); // 5分のウィンドウ
        return 'allow';
    }

    if ($count < 3) {
        set_transient($key, $count + 1, 300);
        return 'allow';
    }

    if ($count < 5) {
        set_transient($key, $count + 1, 300);
        sleep(2); // スロットリング: 2秒の遅延を追加
        return 'throttle';
    }

    return 'block'; // ハードリミットに到達
}

レイヤー3:行動分析による検証

レート制限はクライアントの送信頻度を教えてくれますが、送信が正当かどうかについては何も教えてくれません。行動シグナルを重ねます:honeypotフィールド、タイミング解析、Proof of Workチャレンジ、インタラクションフィンガープリンティング。

これが本当のスパムフィルタリングが行われる場所です。レート制限は量を管理可能に保ちます。行動分析による検証はコンテンツをクリーンに保ちます。

レイヤー4:サイレント失敗

レート制限またはフラグ付けされた送信を拒否する際、200 OKと偽の成功メッセージを返すアプローチがあります。成功レスポンスを見たボットはリトライを止めて次に移りますが、エラーレスポンスを見たボットは適応してより強く試みる傾向があります。ただし、RFC的には429が正しいステータスコードであり、WAFやCDNとの連携上、200を返すと意図しない挙動を生むケースもあるため、運用環境に応じて判断してください。

// レート制限されたボットに偽の成功を返す例(サイレントリジェクション戦略)
if ($result === 'block') {
    wp_send_json([
        'status'  => 'mail_sent',
        'message' => 'Thank you for your message.'
    ]);
    exit; // 実際にはフォーム送信を処理しない
}

Samurai Honeypot for Formsとの統合

Contact Form 7を使用していて、CIDR対応のレート制限、行動分析による検証、Proof of Work、サイレント失敗をゼロから実装するのは手が回らない、というのは当然です。本来すでに解決済みであるべき問題のために、かなりの量のインフラコードが必要になります。

Samurai Honeypot for Forms はこの多層アプローチを1つのプラグインにまとめています。IPv6対応のレート制限、行動エントロピースコアリング、多態的honeypotのローテーション、サイレントリジェクション――すべて外部API呼び出しやユーザー側の摩擦なしで処理します。ほとんどのWordPressサイトにとって、このプラグインをインストールする方が、カスタムのレート制限スタックを構築・保守するよりもはるかに効率的です。

運用上の推奨事項

フォームエンドポイントにレート制限を実装するシステム管理者および開発者向けのチェックリストです:

  1. エッジから始める。 Cloudflare、Nginx、またはApacheのレート制限が最も安価な防御です。これを最初に設定してください。
  2. Nginxのlimit_reqはLeaky Bucketで動作することを理解する。 出力レートを一定に保つアルゴリズムです。burstパラメータでバースト許容量を、nodelayで即時処理を制御できます。
  3. IPv6は/64で集約する。 IPv6でのIPごとの制限は、事実上制限なしと同じです。プレフィックスでグループ化してください。
  4. ブロックする前にログを取る。 制限を適用する前に、1週間「ログのみ」モードで運用してください。トラフィックパターンを分析し、推測ではなく実データに基づいて閾値を設定してください。
  5. レスポンスコードの戦略を検討する。 運用方針によっては、429ではなく200を返して検出ロジックの情報を攻撃者に漏らさないアプローチも有効です。ただしWAF/CDNとの整合性を確認してください。
  6. アラート付きで監視する。 レート制限されたリクエストの急増は、誰かがエンドポイントを探っていることを意味します。そのシグナルは有用です。監視システムにパイプしてください。
  7. レート制限だけに頼らない。 量的な不正使用は止められます。しかし、ローテーションするIPプールから1時間に1件の巧妙に作られた送信を行うボットは止められません。行動分析レイヤーも必要です。

最後に

レート制限は華やかではありません。見出しを飾ったり、カンファレンスで講演したりするようなセキュリティ対策ではありません。しかし、ボット攻撃中にWordPressサイトがオンラインを維持するか、短時間に数千〜数万のリクエストが保護されていないエンドポイントにヒットしてサービスが応答不能に陥るかの違いを生みます。

実装は単純明快です。IPv6の課題は現実のものですが解決可能です。そして、対策をしないコスト――データベース肥大化、ブラックリストに載ったIP、パフォーマンス低下、怒りのサポートチケット――は、limit_req_zoneディレクティブを設定するのに午後を費やすコストよりもはるかに高くつきます。

エンドポイントを保護してください。IPv6プレフィックスを集約してください。防御を多層化してください。ボットは止まりません。あなたのインフラストラクチャが準備を整えておく必要があります。

すべてのコラム