Tech Deep Dive

Contact Form 7をハックする:`wpcf7_skip_mail` フックを使いこなす

· 4 min read

Contact Form 7は、500万以上のアクティブインストールにわたり、年間260億件以上のフォーム送信を処理しています。それにもかかわらず、プラグインにはほぼスパムフィルタリング機能が内蔵されていません。ハニーポットなし。行動分析なし。レートリミティングなし。届いたものをそのまま受け取り、メールを送信するだけです。

これは批判ではなく、設計思想です。CF7は意図的にミニマルに作られています。フック駆動のアーキテクチャを提供し、開発者が拡張することを前提としているのです。問題は、ほとんどの開発者がプラグインの管理UIの先を見ないことです。フィールドを設定し、メールテンプレートを作成して終わり——バリデーションパイプライン全体が手つかずのままです。

本記事では、サーバーレベルで送信をインターセプト、検査、拒否できるwpcf7フックを順に解説します。特に wpcf7_skip_mail とその周辺のフックに焦点を当てます。なぜなら、ユーザーが送信ボタンを押した後に何が起こるかを本当にコントロールできるのは、そこだからです。


問題:CF7はすべてを信用する

デフォルトのContact Form 7は、フィールドタイプの検証(必須フィールド、メール形式、URL形式)を行い、その後メールを送信します。パイプライン全体がそれだけです。「この送信はスパムの可能性があるか?」という概念は、コアに組み込まれていません。

これが重要な理由は、ボットはフィールドバリデーションに失敗しないからです。REST APIやヘッドレスブラウザ経由で送信するボットは、有効な名前、有効なメールアドレス、メッセージ本文を喜んで提供します。すべての必須フィールドが入力されます。すべてのフォーマットチェックを通過します。CF7は完全に有効な送信として処理し、あなたの受信トレイに配信します。

このプラグインのアーキテクチャは、追加のバリデーション——スパムスコアリング、ハニーポットフィールド、トークン検証、行動分析——が必要なら、送信ライフサイクルにフックして自分で追加することを前提としています。

ほとんどの開発者はそうしません。ほとんどの開発者はCAPTCHAプラグインをインストールして終わりにします。ボットはとっくにそれに適応しています。


技術的詳解:CF7の送信ライフサイクル

コードを書く前に、CF7が送信を処理する順序を理解する必要があります。ライフサイクルは以下のようになっています。

  1. フォーム送信を受信 —— CF7がPOSTデータをパースする。
  2. wpcf7_validate フィルター —— フィールドレベルのバリデーションが実行される(必須チェック、フォーマットチェック、フィールドタグごとのカスタムバリデーションフィルター)。
  3. wpcf7_before_send_mail アクション —— バリデーション通過後、メール送信前に発火。ここで送信データの修正や追加チェックが可能。
  4. wpcf7_skip_mail フィルター —— CF7が実際にメールを送信すべきかどうかを決定するbooleanフィルター。true を返すと送信をスキップ。
  5. メール送信(スキップされない限り)。
  6. wpcf7_mail_sent アクション —— メール送信成功後に発火。
  7. wpcf7_mail_failed アクション —— メール送信失敗時に発火。
  8. ブラウザにレスポンスを返す。

ステップ2~4のフックが、サーバー側の防御を構築する場所です。それぞれ異なる目的があります。

wpcf7_validate —— フィールドレベルのバリデーション

送信を拒否できる最も早いポイントです。フィールドごと・タグタイプごとに実行されます。バリデーションロジックが特定のフォームフィールドに紐づいている場合に使用します。

add_filter( 'wpcf7_validate_text*', 'my_custom_name_validation', 20, 2 );

function my_custom_name_validation( $result, $tag ) {
    if ( 'your-name' === $tag->name ) {
        $value = isset( $_POST['your-name'] ) ? trim( $_POST['your-name'] ) : '';

        // 名前にURLが含まれている送信を拒否
        if ( preg_match( '/https?:///i', $value ) ) {
            $result->invalidate( $tag, 'Please enter a valid name.' );
        }
    }

    return $result;
}

このフィルターは text* タイプ(必須テキスト)のすべてのテキストフィールドに対して発火します。第1引数はバリデーション結果オブジェクト、第2引数はフォームフィールドを表すタグオブジェクトです。$result->invalidate() を呼ぶとフィールドが無効としてマークされ、送信が停止します。

制限はスコープです。このフックは一度に1つのフィールドしか見ません。バリデーションロジックが送信全体——タイミングデータ、ハニーポットフィールド、クロスフィールド分析——を評価する必要がある場合は、後段のフックが必要です。

wpcf7_before_send_mail —— 送信全体へのアクセス

このアクションは、すべてのフィールドバリデーションが通過した後に発火します。完全な WPCF7_ContactForm オブジェクトとすべての送信データにアクセスできます。ここで送信全体にわたるチェックを実行します。

add_action( 'wpcf7_before_send_mail', 'my_submission_inspection', 10, 3 );

function my_submission_inspection( $contact_form, &$abort, $submission ) {
    // 送信データにアクセス
    $posted_data = $submission->get_posted_data();

    $name    = isset( $posted_data['your-name'] ) ? $posted_data['your-name'] : '';
    $email   = isset( $posted_data['your-email'] ) ? $posted_data['your-email'] : '';
    $message = isset( $posted_data['your-message'] ) ? $posted_data['your-message'] : '';

    // 例:メッセージに3つ以上のURLが含まれている場合は拒否
    $url_count = preg_match_all( '/https?://[^s]+/', $message );
    if ( $url_count > 3 ) {
        $abort = true;
        $submission->set_status( 'spam' );
        $submission->set_response(
            __( 'Your message could not be sent.', 'my-plugin' )
        );
    }
}

重要な詳細: $abort パラメータは参照渡しです。これを true に設定するとメール送信プロセス全体が中止されます。set_status( 'spam' ) と組み合わせることで、送信をクリーンに拒否し、設定可能なエラーメッセージをブラウザに返します。

このフックは強力ですが、1つの欠点があります。フィルターではなくアクションとして動作するため、戻り値でフローを制御できません。$abort 参照と送信オブジェクトを通じて状態を変更します。よりシンプルな「送信するか否か」ロジックには、次のフックがよりクリーンです。

wpcf7_skip_mail —— キルスイッチ

これが本記事のタイトルにもなっているフックであり、CF7開発者のツールキットの中で最も外科的なツールです。

wpcf7_skip_mailbooleanフィルターです。現在のskip-mail状態と WPCF7_ContactForm オブジェクトを受け取ります。true を返すと、CF7はサイレントにメール送信をスキップします。デフォルトではフォームは「送信成功」レスポンスをブラウザに返します——つまり、ボットは自分の送信が破棄されたことに気づきません。

add_filter( 'wpcf7_skip_mail', 'my_spam_gate', 10, 2 );

function my_spam_gate( $skip_mail, $contact_form ) {
    $submission = WPCF7_Submission::get_instance();

    if ( ! $submission ) {
        return $skip_mail;
    }

    $posted_data = $submission->get_posted_data();

    // --- チェック1: ハニーポットフィールド ---
    // 人間には見えない隠しフィールドが入力されていたら、ボットである。
    $honeypot_value = isset( $posted_data['company-url'] ) ? $posted_data['company-url'] : '';
    if ( ! empty( $honeypot_value ) ) {
        return true; // メールをスキップ — ボット検出
    }

    // --- チェック2: タイミング分析 ---
    // 人間はフォームの入力に最低でも数秒かかる。
    // ボットはミリ秒で送信する。
    $timestamp = isset( $posted_data['_form_timestamp'] ) ? (int) $posted_data['_form_timestamp'] : 0;
    $elapsed   = time() - $timestamp;

    if ( $timestamp > 0 && $elapsed < 3 ) {
        return true; // 送信が速すぎる — ボットの可能性が高い
    }

    // --- チェック3: キーワードブラックリスト ---
    $message   = isset( $posted_data['your-message'] ) ? $posted_data['your-message'] : '';
    $blacklist = array( 'buy backlinks', 'cheap seo', 'free audit', 'casino', 'viagra' );

    foreach ( $blacklist as $keyword ) {
        if ( false !== stripos( $message, $keyword ) ) {
            return true; // 既知のスパムフレーズ
        }
    }

    return $skip_mail; // パススルー — スパムではない
}

このパターンについていくつか注目すべき点があります。

ボットは成功レスポンスを受け取ります。 これは非常に重要です。wpcf7_skip_mail を使用すると、CF7はブラウザにエラーを返しません。標準の成功メッセージを返します。ボットの運用者は200レスポンスと「ありがとうございます」メッセージを見て、スパムが配信されたと認識します。彼らにはアプローチを修正するためのシグナルがありません。これを、バリデーションエラーを返す場合と比較してください。バリデーションエラーは、何が拒否のトリガーとなったかをボットに正確に伝えてしまいます。

複数のチェックを重ねることができます。 上記のコードは、ハニーポット、タイミング分析、キーワードマッチングを単一のフィルター内で組み合わせています。各チェックは独立しています。いずれか1つが送信をフラグすれば、メールはスキップされます。IPレピュテーション、トークン検証、行動スコアリングなど、必要なだけレイヤーを追加できます——フォームテンプレートに触れる必要はありません。

フィルターは合成可能です。 複数のプラグインや関数が wpcf7_skip_mail にフックできます。いずれかが true を返せば、メールはスキップされます。つまり、カスタムロジックは他のセキュリティプラグインと競合なく共存できます。フック優先度の競合を心配する必要はありません——別のフィルターを明示的にオーバーライドしようとしない限り。


ソリューション:完全なサーバー側バリデーションクラス

上記の概念を単一のクラスにまとめた本番環境対応の実装です。wpcf7_before_send_mail(ロギング用)と wpcf7_skip_mail(実際のスパムゲート用)の両方にフックを登録します。

<?php
/**
 * CF7 サーバー側スパムゲート
 *
 * テーマのfunctions.phpまたはカスタムプラグインファイルにドロップインしてください。
 */
class CF7_Spam_Gate {

    /**
     * 人間がフォームを入力するのに最低限必要な秒数。
     */
    const MIN_SUBMIT_TIME = 3;

    /**
     * メッセージ本文に許可されるURLの最大数。
     */
    const MAX_URLS = 2;

    /**
     * フックを初期化。
     */
    public static function init() {
        add_filter( 'wpcf7_skip_mail', array( __CLASS__, 'evaluate_submission' ), 20, 2 );
        add_action( 'wpcf7_before_send_mail', array( __CLASS__, 'log_submission' ), 10, 3 );
    }

    /**
     * 送信を評価し、メールをスキップするかどうかを決定する。
     *
     * @param bool                $skip_mail    現在のskip-mail状態。
     * @param WPCF7_ContactForm   $contact_form フォームインスタンス。
     * @return bool
     */
    public static function evaluate_submission( $skip_mail, $contact_form ) {
        // 別のフィルターが既にスキップを決定している場合はそれを尊重する。
        if ( $skip_mail ) {
            return $skip_mail;
        }

        $submission = WPCF7_Submission::get_instance();
        if ( ! $submission ) {
            return $skip_mail;
        }

        $posted_data = $submission->get_posted_data();

        // チェックを実行。各チェックはスパム検出時にtrueを返す。
        $checks = array(
            self::check_honeypot( $posted_data ),
            self::check_timing( $posted_data ),
            self::check_url_density( $posted_data ),
            self::check_duplicate_submission( $posted_data ),
        );

        // いずれかのチェックが送信をフラグした場合、メールをスキップ。
        if ( in_array( true, $checks, true ) ) {
            // CF7の内部トラッキングでスパムとしてマーク。
            $submission->set_status( 'spam' );
            return true;
        }

        return $skip_mail;
    }

    /**
     * ハニーポットフィールドが入力されたかチェック。
     */
    private static function check_honeypot( $data ) {
        $honeypot = isset( $data['website-url'] ) ? trim( $data['website-url'] ) : '';
        return ! empty( $honeypot );
    }

    /**
     * フォームが人間には不可能な速度で送信されたかチェック。
     */
    private static function check_timing( $data ) {
        $timestamp = isset( $data['_form_timestamp'] ) ? (int) $data['_form_timestamp'] : 0;

        if ( 0 === $timestamp ) {
            return false; // タイムスタンプなし — 評価不能。
        }

        $elapsed = time() - $timestamp;
        return $elapsed < self::MIN_SUBMIT_TIME;
    }

    /**
     * メッセージ本文に過剰なURLが含まれていないかチェック。
     */
    private static function check_url_density( $data ) {
        $message   = isset( $data['your-message'] ) ? $data['your-message'] : '';
        $url_count = preg_match_all( '/https?://[^s]+/', $message );
        return $url_count > self::MAX_URLS;
    }

    /**
     * 短期間内の重複送信をチェック。
     * 送信内容のハッシュをキーとしたtransientを使用。
     */
    private static function check_duplicate_submission( $data ) {
        $message = isset( $data['your-message'] ) ? $data['your-message'] : '';
        $email   = isset( $data['your-email'] ) ? $data['your-email'] : '';
        $hash    = md5( $email . $message );
        $key     = 'cf7_dup_' . $hash;

        if ( get_transient( $key ) ) {
            return true; // 同じ内容が最近送信された。
        }

        // ハッシュを10分間保存。
        set_transient( $key, 1, 10 * MINUTE_IN_SECONDS );
        return false;
    }

    /**
     * デバッグ用に送信メタデータをログに記録。
     * メール送信前(またはスキップ前)に発火。
     */
    public static function log_submission( $contact_form, &$abort, $submission ) {
        if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
            return;
        }

        $posted_data = $submission->get_posted_data();
        $ip          = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown';
        $timestamp   = isset( $posted_data['_form_timestamp'] ) ? (int) $posted_data['_form_timestamp'] : 0;
        $elapsed     = $timestamp > 0 ? ( time() - $timestamp ) : 'N/A';

        error_log( sprintf(
            '[CF7 Spam Gate] Form ID: %d | IP: %s | Elapsed: %s sec | Status: %s',
            $contact_form->id(),
            $ip,
            $elapsed,
            $submission->get_status()
        ) );
    }
}

// クラスを起動。
add_action( 'init', array( 'CF7_Spam_Gate', 'init' ) );

ハニーポットフィールドとタイムスタンプフィールドをフォームに追加する

上記のPHPは、CF7フォームテンプレートに2つの隠しフィールドがあることを前提としています。追加方法はこちらです。

Contact Form 7のフォームエディタで、ハニーポットフィールドとタイムスタンプ用のコンテナを追加します。

<div style="position:absolute;left:-9999px;" aria-hidden="true">
    [text website-url autocomplete:off tabindex:-1]
</div>

[hidden _form_timestamp]

次に、ページロード時にタイムスタンプを設定する小さなスクリプトをエンキューします。

document.addEventListener( 'DOMContentLoaded', function() {
    var fields = document.querySelectorAll( 'input[name="_form_timestamp"]' );
    var now    = Math.floor( Date.now() / 1000 );

    fields.forEach( function( field ) {
        field.value = now;
    });
});

ハニーポットフィールドは画面外に配置されています。目の見えるユーザーには見えません。スクリーンリーダーは aria-hidden="true" によりスキップします。しかしボットは website-url という名前の入力フィールドを認識し、入力すべき正当なフィールドだと判断します。入力されれば、check_honeypot() がそれを捕捉します。

タイムスタンプフィールドはページがロードされた時刻を記録します。送信時にサーバーが差分を計算します。3秒未満で到着した送信は、ほぼ確実に自動化されたものです。


なぜ wpcf7_validate ではなく wpcf7_skip_mail なのか

スパムゲートに wpcf7_validate で送信を完全に拒否するのではなく、なぜ wpcf7_skip_mail を使うのか疑問に思うかもしれません。理由は2つあります。

1. 情報漏洩。 wpcf7_validate でフィールドを無効化すると、CF7はブラウザにバリデーションエラーを返します。そのエラーメッセージは、ボットにどのチェックに失敗したかを正確に伝えます。ボット運用者は、ペイロードが通過するまであなたのバリデーションロジックに対して反復的にテストできます。wpcf7_skip_mail では、ボットは成功レスポンスを受け取ります。ボットの視点からは、スパムが配信されたことになります。反復テストする対象がありません。

2. 関心の分離。 フィールドバリデーションとスパム検出は異なる問題です。フィールドバリデーションは「このデータは適切な形式か?」に答えます。スパム検出は「この送信は正当か?」に答えます。これらを同じフックに混在させると、保守が困難な脆弱なコードが生まれます。スパムロジックを wpcf7_skip_mail に保持することで、フィールドバリデーションルールとは独立して変更、有効化、無効化が可能になります。


さらなる一歩:DIYでは足りなくなるとき

上記のクラスは基本的なスパムパターンに対して効果的に機能します。単純なボットやフォームマーケティングツールからの自動送信の大部分をブロックできるでしょう。

しかし死角があります。ハニーポットフィールド名は静的であるため、あなたのサイトを特定して攻撃するボットは、そのフィールドを空にすることを学習できます。タイミング閾値はネットワーク遅延を考慮しない固定定数です。キーワードブラックリストは手動メンテナンスが必要です。そしてJavaScriptを実行しタイムスタンプフィールドに正直に入力するフルヘッドレスブラウザを使うボットに対する保護はありません。

現代のボットはcurlリクエストを投げる単純なスクリプトではありません。PuppeteerやPlaywrightで駆動されるヘッドレスChromiumインスタンスであり、あなたのJavaScriptを実行し、CSSをレンダリングし、人間のブラウザとまったく同じようにDOMと対話します。これらに対しては、静的ハニーポットと固定閾値では不十分です。

ここが専用プラグインの価値が発揮されるところです。Samurai Honeypot for Forms は本記事のコンセプトを拡張し、ポリモーフィックフィールド名(ハニーポットフィールドの名前と位置がページロードごとに変化)、サーバー側トークン検証(リプレイできないステートレスHMACトークン)、多層行動分析をすべてサーバー側で実行します。外部APIコールなし、Cookieなし。これは私たちが手作業で構築してきたパターンの、本番環境グレードの実装です。

スパムの量が少なく脅威モデルがシンプルなら、上記のカスタムクラスで十分でしょう。しかし、持続的で適応力のあるボットに大規模に対処しているなら、やがて限界に達するでしょう。


まとめ

  • CF7はフレームワークであり、完全なソリューションではありません。 そのフック駆動のアーキテクチャは、wpcf7_validatewpcf7_before_send_mailwpcf7_skip_mail を通じてバリデーションロジックを追加することを前提としています。
  • wpcf7_skip_mail はスパム検出に最適なフックです。ボットに情報を漏洩することなく、スパムをサイレントに破棄できるからです。
  • 防御を重ねましょう。 ハニーポット、タイミング分析、コンテンツ検査、重複検出を組み合わせます。単一のチェックですべてを捕捉することはできません。
  • ボットには成功を返す。 攻撃者に送信が拒否されたことを決して教えてはいけません。「ありがとうございます」メッセージこそ、あなたが持つ最良の欺瞞です。
  • 静的な防御には有効期限があります。 ハードコードされたフィールド名、固定閾値、キーワードリストは継続的なメンテナンスが必要です——あるいは、その進化を自動的に処理してくれるプラグインが必要です。
すべてのコラム