0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第10回】F5連打・スクリプト荒らしを許さない!秒単位の連投規制(レートリミット)とUXの両立

0
Posted at

「1秒間に100回の投稿スクリプトが走り、大切なスレッドが一瞬で1000レス完走してdat落ちした」
令和に自作した2ちゃんねる風掲示板「つゔぁいちゃんねる(2ch.biz)」開発秘話の第10弾。今回は、匿名掲示板最大の天敵である「埋め立て荒らし」と「スレ乱立スクリプト」をサーバーリソースを消費させずに撃退しつつ、一般ユーザーにはストレスを感じさせないレートリミット(連投規制)の仕組みを解説します。


💡 はじめに:掲示板を破壊する「埋め立てテロ」の恐怖

2ちゃんねる系のコミュニティにおいて、最も凶悪かつ防ぐのが難しい攻撃が 「埋め立て荒らし」 です。

攻撃者はPythonの requests や簡単なシェルスクリプトを使い、以下のようなリクエストを秒間数十回〜数百回送り込んできます。

# 攻撃スクリプトの例(概念)
while true; do
  curl -s -X POST https://2ch.biz/api.php?action=create_post \
       -d "board=vip&thread=5&content=ああああああああああああ" &
done

第3回で解説した通り、2chスタイルの掲示板は 「1000レスに達すると自動的に書き込み停止(過去ログ化・dat落ち)」 する仕様になっています。
つまり、悪意あるスクリプトが1秒間に数十レスを連投すれば、 みんなで楽しく雑談していた良スレがわずか数十秒で埋め立てられ、二度と書き込めなくなってしまう のです。

さらに、新規スレッドを秒速で乱立されれば、板の保持上限(300スレ)があっという間に溢れ、他の正常なスレッドが次々と押し出されて消滅します。

このテロ行為を物理的に遮断するため、開発初期(2026年8月18日)に 「高精度・非対称レートリミットシステム」 を実装しました。


📐 非対称レートリミットの設計思想

一律に「アクセスは1分間に10回まで」のような大雑把なレートリミットをかけると、サイトの快適性が死にます。
掲示板において、 「レス投稿」と「スレッド作成」ではサーバー負荷もユーザー行動も全く異なる からです。

操作種別 特徴・サーバー負荷 適切な連投間隔 荒らし対策の目的
レス投稿 雑談でポンポン会話が弾む日常動作。負荷は中程度。 5秒〜8秒 1000レス埋め立てスクリプトの阻止
スレッド作成 新しい議論の立ち上げ。高頻度で行う必要がなく、負荷が大きい。 60秒〜120秒 板の保持上限超過(押し出し荒らし)の阻止

つゔぁいちゃんねるでは、この2つを別々のタイマーで独立して追跡する 「非対称レートリミット」 を採用しました。


🏗️ データベース設計とUPSERTによる高速記録

IPアドレスごとに「最終レス投稿時刻」と「最終スレッド作成時刻」を記録する軽量テーブル post_rate_limits を設計しました。

CREATE TABLE IF NOT EXISTS post_rate_limits (
    ip VARCHAR(45) PRIMARY KEY,
    last_post_at INTEGER NOT NULL DEFAULT 0,    -- UNIXタイムスタンプ(秒)
    last_thread_at INTEGER NOT NULL DEFAULT 0,  -- UNIXタイムスタンプ(秒)
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

なぜSELECTしてからUPDATEしないのか?(UPSERTの活用)

投稿のたびに SELECT して、存在確認してから INSERT や UPDATE を別クエリで発行すると、高負荷時にデッドロックやレースコンディション(競合状態)が発生します。
SQLiteやMySQLの UPSERT構文(ON DUPLICATE KEY UPDATE / ON CONFLICT DO UPDATE) を用いて、1クエリ・数ミリ秒で時刻を更新します。


💻 レートリミット判定エンジンの実装(ap_board.php 抜粋)

投稿APIのコントローラー最前段で実行されるバリデーションロジックです。

public function checkRateLimit($clientIp, $type = 'post') {
    $now = time();
    $settings = $this->getRateLimitSettings();
    
    // 設定された制限秒数を取得(0なら無制限)
    $limitSeconds = ($type === 'thread') 
        ? (int)$settings['thread_interval_seconds'] // デフォルト: 60秒
        : (int)$settings['post_interval_seconds'];   // デフォルト: 5秒

    if ($limitSeconds <= 0) {
        return; // レートリミット無効
    }

    // IPの最終投稿レコードを取得
    $stmt = $this->db->prepare("SELECT last_post_at, last_thread_at FROM post_rate_limits WHERE ip = :ip");
    $stmt->execute([':ip' => $clientIp]);
    $record = $stmt->fetch(PDO::FETCH_ASSOC);

    $lastTime = 0;
    if ($record) {
        $lastTime = ($type === 'thread') ? (int)$record['last_thread_at'] : (int)$record['last_post_at'];
    }

    $elapsed = $now - $lastTime;
    if ($elapsed < $limitSeconds) {
        $remaining = $limitSeconds - $elapsed;
        $actionName = ($type === 'thread') ? 'スレッド作成' : '投稿';
        
        // ⚠️ HTTP 429 Too Many Requests を返却
        http_response_code(429);
        echo json_encode([
            'success' => false,
            'code' => 'RATE_LIMITED',
            'remaining_seconds' => $remaining,
            'message' => "{$actionName}の間隔が短すぎます。あと {$remaining} 秒お待ちください。"
        ]);
        exit;
    }

    // 制限をクリアした場合、現在時刻をアトミックに記録
    if ($type === 'thread') {
        $sql = "INSERT INTO post_rate_limits (ip, last_thread_at) VALUES (:ip, :now)
                ON CONFLICT(ip) DO UPDATE SET last_thread_at = :now";
    } else {
        $sql = "INSERT INTO post_rate_limits (ip, last_post_at) VALUES (:ip, :now)
                ON CONFLICT(ip) DO UPDATE SET last_post_at = :now";
    }
    $updateStmt = $this->db->prepare($sql);
    $updateStmt->execute([':ip' => $clientIp, ':now' => $now]);
}

🎨 一般ユーザーをイラつかせない「優しいUX設計」

レートリミットを導入する際、最も注意すべきは 「普通に雑談している一般ユーザーが偶然引っかかった時の精神的ストレス」 です。

単に「エラー: 連投禁止です」とだけ表示されると、ユーザーは「えっ、いつまで待てばいいの?」「もう1回押していいの?」と不安になり、何度も送信ボタンを連打して傷口を広げてしまいます。

1. 「あと〇秒お待ちください」のカウントダウン表示

APIレスポンスに含まれる remaining_seconds を利用し、フォームの直下に分かりやすい赤字で残り秒数を明示します。

❌ 投稿の間隔が短すぎます。あと 4 秒お待ちください。

2. 送信ボタンの一時的無効化(Double Submit Protection)

ユーザーが誤ってマウスをダブルクリックしたり、通信遅延中に連打したせいで同じレスが2連続で投稿されてしまう現象を防ぐため、送信ボタンをクリックした瞬間に即座に disabled 化し、ボタンの文言を「投稿中...」に切り替えます。

// app.js の送信保護
async function submitPost() {
    const submitBtn = document.getElementById('btnSubmitPost');
    submitBtn.disabled = true;
    submitBtn.textContent = '送信中...';

    try {
        const res = await fetch('/api.php?action=create_post', { /* ... */ });
        const data = await res.json();
        
        if (!data.success && data.code === 'RATE_LIMITED') {
            showErrorBanner(data.message);
            // 残り秒数が経過したらボタンを自動復旧
            setTimeout(() => {
                submitBtn.disabled = false;
                submitBtn.textContent = '書き込む';
            }, data.remaining_seconds * 1000);
            return;
        }
        // 成功時の処理...
    } finally {
        submitBtn.disabled = false;
        submitBtn.textContent = '書き込む';
    }
}

⚙️ 管理画面からのリアルタイム調整

サーバーが平和な通常時は「5秒」で十分ですが、大規模な荒らし攻撃が発生した緊急時には、管理画面から即座に「15秒」や「30秒」へ引き上げられるようGUI設定パネルを設けました。

  • レス投稿最小間隔 : デフォルト5秒(0秒で無効化)
  • 新規スレ作成最小間隔 : デフォルト60秒(0秒で無効化)

PHPのコードを直接書き換えることなく、管理画面のフォームから保存するだけで全リクエストへ瞬時に反映されます。


🎯 まとめと次回予告

第10回となる今回は、掲示板の平和とサーバーの命を守る 「秒単位の非対称レートリミット(連投規制)」 について解説しました。

今回の学び・ポイント

  • 非対称な制限間隔 :レス(5秒)とスレッド作成(60秒)で全く異なるリスクに応じた個別制御。
  • UPSERTによる高速記録 :高負荷時でもデッドロックを起こさず、ミリ秒で判定を完了させる。
  • 親切なエラー通知 :「あと〇秒」を明示し、ボタンを自動制御することで一般ユーザーの不満を最小化する。

これで、スクリプトによる高速連投は完全に封じ込めました。
しかし、荒らしは手口を変えてきます。
「5秒おきに、全く同じコピペ文章を全スレに貼りまくるマルチポスト爆撃」 です。

次回、 【第11回】「マルチポスト」「コピペ爆撃」を瞬時に弾く!ハッシュによる同一内容重複投稿防止機構 へと続きます!


🔗 関連リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?