本日1件のお問い合わせがありました。

✉️FIELDにお問い合わせ

システム開発

AI時代の問い合わせフォームBOT対策30選|PHP / Laravel / WordPress / Nginx の実装コード付き

問い合わせフォームに届く迷惑メールを「スパムが増えたな」で片付けていると、実際に起きていることを見落とします。AI時代のBOTがフォームを叩く目的は、迷惑メールの送信だけではありません。営業リスト収集・不正広告・脆弱性調査・アカウント侵入・API/メール送信費の増大・サービス妨害という、はっきりした狙いがあります。

やっかいなのは、生成AIの普及によって日本語として自然な問い合わせ文や、会社情報を読んだうえでの営業文が自動生成できるようになったことです。従来のスパム判定は「日本語かどうか」「文章が不自然かどうか」に頼っていましたが、この前提はすでに成立しません。

この記事では、問い合わせフォームまわりで実際に起きるBOT攻撃を30種類に整理し、それぞれに対する実装対策を PHP / Laravel / WordPress / Nginx のコード付きでまとめます。


AI時代のBOTは「迷惑メール」だけを狙っていない

まず、攻撃側の目的を整理します。フォームは「外部から自社サーバーに任意の文字列を送り込める窓口」なので、狙われる理由はひとつではありません。

  • 営業リスト収集:フォームやサイト上のメールアドレス・担当者名を機械的に集める
  • 不正広告・SEO:被リンクや誘導URLをばら撒く
  • 脆弱性調査:WordPressのプラグインや管理URLの穴を探索する
  • アカウント侵入:総当たりや流出パスワードの再利用でログインを試す
  • API・メール送信費の増大:従量課金のAI API、メール、SMSの費用をこちらに払わせる
  • サービス妨害:接続やPHPワーカーを占有してサイトを止める

OWASPも、スパム、スクレイピング、認証攻撃、アカウント作成、コスト増大、DoSなどをそれぞれ異なる自動化脅威として分類しています(OWASP Automated Threats)。「BOT対策」と一括りにせず、目的別に対策を当てるのが実務では正解です。


BOT攻撃・目的・コード対策30選

以下は、PHP/Laravel/WordPress/Nginxを想定した30種類の一覧です。まずここで「自分のサイトはどれを踏んでいるか」を確認してください。

#攻撃・悪用BOTの目的主な実装対策
1問い合わせスパム広告・SEO・営業メール送信ハニーポット+送信時間
2AI生成営業文人間らしい文章で判定回避行動スコア+頻度制限
3URL宣伝スパム被リンク・誘導・詐欺URL数制限+禁止ドメイン
4メールボム担当者の受信箱を妨害IP・メール単位の制限
5メールリレー悪用第三者への迷惑メール送信宛先をサーバー側で固定
6ヘッダーインジェクションBcc追加・メール乗っ取り改行拒否
7SMS認証爆撃SMS料金を発生させる電話番号単位の制限
8AI API料金攻撃OpenAI等の従量課金を増大非同期処理+日次予算
9ファイル大量送信容量・CPU・メールを圧迫サイズ・種類・個数制限
10マルウェア添付社内端末への感染MIME検証+ウイルス検査
11PHP偽装画像Webシェル設置拡張子でなく実データ検査
12Zip爆弾解凍時に容量・CPUを枯渇圧縮ファイル拒否/展開上限
13SQLインジェクションDB窃取・改ざんプレースホルダー
14XSS投稿管理画面でスクリプト実行エスケープ+CSP
15CSVインジェクションCSVを開いたPCで数式実行先頭記号を無害化
16コマンドインジェクションサーバー上でOS命令実行shellを使わない
17SSRF内部API・AWSメタデータ探索外部URL取得禁止/許可制
18XXEXML経由で内部ファイル窃取XML禁止または外部実体無効
19CSRF本人になりすまして送信CSRFトークン
20リプレイ攻撃正常リクエストを再利用ワンタイムID
21CAPTCHA突破人力・AIで認証回避サーバー検証+多層判定
22レート制限回避IPを変えながら大量送信IP以外の複合キー
23プロキシ偽装X-Forwarded-Forを偽装信頼プロキシを限定
24ユーザー列挙登録済みメールを収集同一レスポンス
25パスワード総当たり管理画面への侵入MFA+段階的遅延
26Credential Stuffing流出パスワードの再利用MFA+端末・IP評価
27スクレイピングメール・価格・記事収集WAF+速度制御
28脆弱性スキャンWordPress等の穴を探索不要URL遮断+更新
29DoS・Slow POST接続やPHPワーカーを占有Nginx制限+タイムアウト
30ログ汚染ログ解析妨害・容量枯渇制御文字除去+保存上限

1. Laravelの基本フォームとレート制限

まずは入口です。ルートにレート制限のミドルウェアを付けます。

// routes/web.php
use App\Http\Controllers\ContactController;

Route::post('/contact', [ContactController::class, 'store'])
    ->middleware(['web', 'throttle:contact']);

制限の中身は AppServiceProvider で定義します。

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;

public function boot(): void
{
    RateLimiter::for('contact', function (Request $request) {
        $email = mb_strtolower((string) $request->input('email'));

        return [
            Limit::perMinute(5)->by('ip:'.$request->ip()),
            Limit::perHour(3)->by('email:'.hash('sha256', $email)),
        ];
    });
}

IPだけで制限すると、携帯回線や会社ネットワークを共有する正常ユーザーを巻き込みます。反対に、BOT側はIPを簡単に変更できます。そのため「IP+メールアドレス+Cookie+電話番号」などを組み合わせた複合キーで数えるのが実務的です。

2. ハニーポット(人間には見えない入力欄)

人間には見えず、単純なBOTだけが入力してしまう項目を仕込みます。

<div class="hp-field" aria-hidden="true">
    <label>
        会社サイト
        <input
            type="text"
            name="company_url_confirm"
            tabindex="-1"
            autocomplete="off"
        >
    </label>
</div>

<style>
.hp-field {
    position: absolute !important;
    left: -10000px !important;
    width: 1px;
    height: 1px;
    overflow: hidden;
}
</style>

サーバー側では、値が入っていれば弾きます。

if ($request->filled('company_url_confirm')) {
    logger()->warning('contact_bot', [
        'reason' => 'honeypot',
        'ip_hash' => hash('sha256', $request->ip()),
    ]);

    // BOTに対策内容を知らせないため、成功風に返す方法もある
    return response()->json(['message' => '受け付けました']);
}

display:none ではなく画面外に飛ばしているのは、単純なBOTが「非表示の項目は無視する」判定を持っている場合があるためです。なお、ハニーポットだけでは実際にブラウザを操作する高度なBOTには突破されます。

3. 送信時間による判定(HMAC署名つき)

ページを表示してから1秒未満で送信されるようなケースは、BOTの可能性が高いと判断できます。

// フォーム表示時
$issuedAt = time();
$signature = hash_hmac('sha256', (string) $issuedAt, config('app.key'));
<input type="hidden" name="issued_at" value="{{ $issuedAt }}">
<input type="hidden" name="form_signature" value="{{ $signature }}">

受け取り側では、署名を検証してから経過時間を見ます。

$issuedAt = (int) $request->input('issued_at');
$expected = hash_hmac('sha256', (string) $issuedAt, config('app.key'));

abort_unless(
    hash_equals($expected, (string) $request->input('form_signature')),
    422
);

$elapsed = time() - $issuedAt;

if ($elapsed < 3 || $elapsed > 7200) {
    abort(422, 'フォームの有効期限が切れています');
}

時刻を hidden に置くだけでは改ざんされて意味がなくなるので、必ずHMAC署名を付けます

4. URL宣伝スパムを制限する

本文中のURL数に上限を設けます。

$message = (string) $request->input('message');

$urlCount = preg_match_all(
    '~(?:https?://|www\.)[^\s]+~iu',
    $message
);

if ($urlCount > 2) {
    abort(422, 'URLが多すぎます');
}

あわせて禁止ドメインも判定します。

$blockedDomains = [
    'example-spam.tld',
    'short-link.example',
];

foreach ($blockedDomains as $domain) {
    if (stripos($message, $domain) !== false) {
        abort(422);
    }
}

ただし完全拒否にすると誤検知が痛いので、後述するスコアを加算して隔離する方法のほうが運用は安定します。

5. メールヘッダーインジェクション対策

氏名やメールアドレスに改行が混ざると、Bccの追加などメールヘッダーの乗っ取りが可能になります。

$email = (string) $request->input('email');
$name  = (string) $request->input('name');

if (preg_match('/[\r\n]/', $email.$name)) {
    abort(422, '不正な改行が含まれています');
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    abort(422, 'メールアドレスが不正です');
}

そしてこれが重要ですが、フォーム入力値をメールの送信先にしてはいけません。第三者への迷惑メール送信(メールリレー悪用)に直結します。

// 危険
Mail::to($request->input('to'))->send($mail);

// 安全:送信先は設定ファイル側で固定
Mail::to(config('contact.recipient'))->send($mail);

6. 入力値の長さと型を制限する

$data = $request->validate([
    'name'    => ['required', 'string', 'max:100'],
    'email'   => ['required', 'email:rfc', 'max:254'],
    'tel'     => ['nullable', 'string', 'max:30'],
    'subject' => ['required', 'string', 'max:150'],
    'message' => ['required', 'string', 'min:10', 'max:5000'],
]);

クライアント側の maxlength だけでは無効です。BOTはHTMLフォームを一切通さず、送信先のエンドポイントへ直接POSTできます。

7. SQLインジェクション対策

// 危険
$result = DB::select(
    "SELECT * FROM contacts WHERE email = '$email'"
);

// 安全
$result = DB::select(
    'SELECT * FROM contacts WHERE email = ?',
    [$email]
);

Eloquentを使う場合も値はバインドされます。

$contact = Contact::where('email', $email)->first();

ただし、カラム名や並び順をユーザー入力から直接組み立てるのは危険です。ここはバインドできないので、必ず許可リストで受けます。

$allowedSorts = ['created_at', 'name', 'email'];
$sort = $request->input('sort');

abort_unless(in_array($sort, $allowedSorts, true), 422);

Contact::orderBy($sort)->get();

8. XSS対策とCSP

保存時に無理にHTML化せず、出力時にエスケープするのが原則です。

{{-- 安全 --}}
<p>{{ $contact->message }}</p>

{{-- 危険 --}}
<p>{!! $contact->message !!}</p>

管理画面で問い合わせ本文を表示する箇所は特に狙われます。レスポンスヘッダーも設定しておきます。

add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
always;

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

9. CSVインジェクション対策

問い合わせ一覧をCSVで出力するとき、セルの先頭が = + - @ だと、表計算ソフトで数式として実行される可能性があります。被害を受けるのはCSVを開いた担当者のPCです。

function safeCsvCell(?string $value): string
{
    $value = $value ?? '';

    if (preg_match('/^[=\-+@]/', ltrim($value))) {
        return "'".$value;
    }

    return $value;
}
fputcsv($handle, [
    safeCsvCell($contact->name),
    safeCsvCell($contact->email),
    safeCsvCell($contact->message),
]);

10. ファイルアップロード対策

$data = $request->validate([
    'attachment' => [
        'nullable',
        'file',
        'max:5120',
        'mimes:pdf,jpg,jpeg,png',
        'mimetypes:application/pdf,image/jpeg,image/png',
    ],
]);

公開ディレクトリには保存しません。

$path = $request->file('attachment')
    ->store('contact-quarantine', 'local');

元のファイル名もそのまま使わないようにします。

$storedName = bin2hex(random_bytes(16)).'.'.$extension;

画像なら再エンコードすると、画像に紛れ込んだ不要データ(PHP偽装画像など)を落としやすくなります。

$image = imagecreatefromstring(
    file_get_contents($request->file('attachment')->getRealPath())
);

if ($image === false) {
    abort(422, '画像として読み込めません');
}

imagejpeg($image, $safePath, 90);
imagedestroy($image);

マルウェア検査はClamAV等を別サービスとして動かし、検査が終わるまで隔離します。OWASPも添付ファイルについてウイルス検査とサイズ制限を推奨しています(OWASP Web Service Security)。

11. CSRF対策とOrigin検証

Laravelの通常フォームなら、これを必ず入れます。

<form method="POST" action="/contact">
    @csrf
</form>

外部APIとして公開する場合は、「CSRFを外して終わり」にせず、署名・Origin検証・CAPTCHA・レート制限を別途実装します。

$allowedOrigins = [
    'https://field.asia',
    'https://www.field.asia',
];

$origin = $request->header('Origin');

if ($origin && !in_array($origin, $allowedOrigins, true)) {
    abort(403);
}

RefererOrigin はあくまで補助判定です。存在しないことだけを理由に必ず拒否すると、正常な送信を巻き込む場合があります

12. リプレイ攻撃の防止(ワンタイムID)

フォーム表示時にワンタイムIDを発行します。

$nonce = bin2hex(random_bytes(32));

Cache::put(
    'contact_nonce:'.hash('sha256', $nonce),
    true,
    now()->addMinutes(30)
);

送信時は、存在確認と削除を一度の操作で行います。

$key = 'contact_nonce:'.hash(
    'sha256',
    (string) $request->input('nonce')
);

if (!Cache::pull($key)) {
    abort(409, 'すでに送信済みです');
}

サーバーが複数台ある構成では、Redisなどサーバー間で共有できるキャッシュを使わないと意味がなくなります。

13. Turnstileは必ずサーバー側で検証する

use Illuminate\Support\Facades\Http;

$result = Http::asForm()
    ->timeout(5)
    ->post(
        'https://challenges.cloudflare.com/turnstile/v0/siteverify',
        [
            'secret'   => config('services.turnstile.secret'),
            'response' => $request->input('cf-turnstile-response'),
            'remoteip' => $request->ip(),
        ]
    )
    ->json();

if (!($result['success'] ?? false)) {
    abort(422, 'BOT認証に失敗しました');
}

ブラウザ側にウィジェットを置くだけでは不十分です。Turnstileトークンはサーバー検証が必須で、有効期間は5分・使用は一回限りです(Cloudflare公式 Siteverify)。

また、CAPTCHAが成功しただけで全面的に信用しないでください。突破される前提で、レート制限やハニーポットも併用します。

14. 複合BOTスコアで判定する

ひとつの条件で即拒否するのではなく、複数の不審点を加算して段階的に扱うと誤検知が減ります。

$score = 0;
$reasons = [];

if ($request->filled('company_url_confirm')) {
    $score += 100;
    $reasons[] = 'honeypot';
}

if ($elapsed < 3) {
    $score += 40;
    $reasons[] = 'too_fast';
}

if ($urlCount >= 2) {
    $score += 25;
    $reasons[] = 'many_urls';
}

if (mb_strlen($message) > 3000) {
    $score += 10;
    $reasons[] = 'long_message';
}

if (preg_match('/(.)\1{10,}/u', $message)) {
    $score += 20;
    $reasons[] = 'repeated_chars';
}

if ($score >= 80) {
    return response()->json(['message' => '受け付けました']);
}

if ($score >= 40) {
    Contact::create([
        ...$data,
        'status' => 'quarantine',
        'risk_score' => $score,
    ]);

    return response()->json(['message' => '受け付けました']);
}

なお、AI生成文かどうかを推測する「AI検出器」だけに依存するのはおすすめしません。正当な文章を誤判定しやすいためです。

15. 同文・類似文の連投を防ぐ

$normalized = mb_strtolower($message);
$normalized = preg_replace('/\s+/u', '', $normalized);
$messageHash = hash('sha256', $normalized);

$key = 'contact_message:'.$messageHash;

if (Cache::has($key)) {
    abort(429, '同じ内容が短時間に送信されています');
}

Cache::put($key, true, now()->addHours(24));

個人情報が含まれる本文を、Redisキーやログへそのまま保存しないのがポイントです。必ずハッシュ化します。

16. AI・メール・SMSの費用攻撃を防ぐ

問い合わせを受信した直後に、いきなり高額なAPIを呼ばないようにします。

// 悪い例:未検証の全問い合わせで即AI処理
$summary = OpenAI::summarize($message);

// 良い例:検証後にキュー投入
AnalyzeContact::dispatch($contact->id)
    ->onQueue('contact-ai');

そのうえで日次予算を設定します。

$todayKey = 'ai_contact_count:'.now()->format('Y-m-d');
$count = Cache::increment($todayKey);

Cache::put($todayKey, $count, now()->endOfDay());

if ($count > 500) {
    logger()->critical('AI daily limit reached');
    return;
}

同じ考え方で、SMS送信数・自動返信数・翻訳API回数にも上限を付けます。ここを止めていないと、攻撃が成功した瞬間に請求書として跳ね返ってきます。

17. NginxでPOST・Slow POSTを制限する

http {
    limit_req_zone $binary_remote_addr
        zone=contact_rate:10m
        rate=5r/m;

    limit_conn_zone $binary_remote_addr
        zone=per_ip:10m;

    server {
        client_max_body_size 6m;
        client_body_timeout 10s;
        client_header_timeout 10s;
        keepalive_timeout 15s;
        send_timeout 15s;

        location = /contact {
            limit_req zone=contact_rate burst=3 nodelay;
            limit_conn per_ip 5;

            proxy_pass http://app;
        }
    }
}

アプリまで到達させずに入口で落とすのが目的です。制限超過時は 429 Too Many Requests、容量超過時は 413 Payload Too Large を返すのが適切です(OWASP REST Security)。

18. WordPressでの最小実装例

WordPressの場合は admin-post.php 経由の実装が扱いやすいです。

add_action('admin_post_nopriv_field_contact', 'field_contact');
add_action('admin_post_field_contact', 'field_contact');

function field_contact(): void
{
    if (
        !isset($_POST['field_contact_nonce']) ||
        !wp_verify_nonce(
            sanitize_text_field(
                wp_unslash($_POST['field_contact_nonce'])
            ),
            'field_contact'
        )
    ) {
        wp_die('Invalid request', '', ['response' => 403]);
    }

    $honeypot = sanitize_text_field(
        wp_unslash($_POST['company_url_confirm'] ?? '')
    );

    if ($honeypot !== '') {
        wp_safe_redirect(home_url('/thanks/'));
        exit;
    }

    $name = sanitize_text_field(
        wp_unslash($_POST['name'] ?? '')
    );

    $email = sanitize_email(
        wp_unslash($_POST['email'] ?? '')
    );

    $message = sanitize_textarea_field(
        wp_unslash($_POST['message'] ?? '')
    );

    if (
        !$name ||
        !is_email($email) ||
        mb_strlen($message) > 5000 ||
        preg_match('/[\r\n]/', $email)
    ) {
        wp_die('Invalid input', '', ['response' => 422]);
    }

    $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
    $rate_key = 'field_contact_' . hash('sha256', $ip);

    if ((int) get_transient($rate_key) >= 5) {
        wp_die('Too many requests', '', ['response' => 429]);
    }

    set_transient(
        $rate_key,
        (int) get_transient($rate_key) + 1,
        10 * MINUTE_IN_SECONDS
    );

    wp_mail(
        get_option('admin_email'),
        'お問い合わせ',
        $message,
        ['Reply-To: '.$email]
    );

    wp_safe_redirect(home_url('/thanks/'));
    exit;
}

フォーム側はこうなります。

<form method="post"
      action="<?php echo esc_url(admin_url('admin-post.php')); ?>">

    <input type="hidden" name="action" value="field_contact">

    <?php wp_nonce_field(
        'field_contact',
        'field_contact_nonce'
    ); ?>

    <input type="text" name="name" maxlength="100" required>
    <input type="email" name="email" maxlength="254" required>
    <textarea name="message" maxlength="5000" required></textarea>

    <div class="hp-field" aria-hidden="true">
        <input
            type="text"
            name="company_url_confirm"
            tabindex="-1"
            autocomplete="off"
        >
    </div>

    <button type="submit">送信</button>
</form>

本格運用では WordPress の transient に頼らず、Cloudflare WAF、Nginx、Redis など入口側でも制限します。transientによるレート制限は、PHPまで処理が届いてしまっている時点で最後の砦にすぎません。

19. ログを安全に残す

ログ自体が攻撃対象になります。制御文字を除去し、長さの上限を決めます。

function cleanLogValue(string $value): string
{
    return mb_substr(
        preg_replace('/[\x00-\x1F\x7F]/u', '', $value),
        0,
        300
    );
}

logger()->warning('contact_rejected', [
    'request_id' => (string) Str::uuid(),
    'reason'     => $reason,
    'ip_hash'    => hash('sha256', $request->ip()),
    'ua'         => cleanLogValue(
        (string) $request->userAgent()
    ),
]);

以下はログにそのまま保存しないようにします。

  • 問い合わせ本文
  • 氏名・電話番号
  • メールアドレス
  • CAPTCHAトークン
  • Cookie・セッションID
  • APIキー
  • パスワード

セキュリティログは「いつ・どのエンドポイントで・どの判定理由だったか」が追跡できる形にします(OWASP Logging Cheat Sheet)。


現実的な導入優先順位

30種類すべてを一度に実装する必要はありません。まず以下の10個を導入すれば、一般的な問い合わせBOTの大部分に対応できます。

  1. Cloudflare WAF
  2. Turnstileのサーバー側検証
  3. IP+メールアドレス単位のレート制限
  4. ハニーポット
  5. 送信時間+HMAC署名
  6. ワンタイムnonce
  7. URL数・本文長の制限
  8. 添付ファイルの隔離と検査
  9. メール・SMS・AI APIの日次上限
  10. 判定理由別のログとアラート

まとめ:目的は「見破ること」ではなく「上限を作ること」

重要なのはBOTを100%見破ることではありません。生成AIが自然な日本語を書ける以上、文章だけで人間かどうかを判定し切ることは不可能です。

実務のゴールは、攻撃者側のコストを上げると同時に、こちらのメール送信費・AI利用費・サーバー負荷・人的確認コストに明確な上限を設けることです。上限さえ効いていれば、突破されても被害は有限で済みます。

FIELDでは、こうしたフォームまわりのセキュリティ実装や、WordPress/Laravelサイトの改修・運用も承っています。「最近スパムが増えた」「AI APIの請求が読めない」といった段階でもお気軽にご相談ください。

Yamamoto Yuya

プロフェッショナルとしての高いスキルと知識を持ち、誠実さと責任感を大切にする。常に向上心を持ち、新たな挑戦にも積極的に取り組む努力家。