【WordPress】お問い合わせフォームを配布プラグインなしで自作した構成とスパム対策
WordPress でお問い合わせフォームを作るとき、まず配布のフォームプラグインを入れる人が多いと思います。この記事では、オレオレナリのサイトで配布プラグインを使わず、フォームの送信処理を自作したときの構成と、スパム対策の中身を紹介します。
- フォームを自作するときの、テーマとプラグインの役割の分け方
- 入力欄の name 属性で WordPress が 404 になる落とし穴と、その避け方
- nonce・送信までの時間・ハニーポット・送信回数・Turnstile の5段構えのスパム対策
- 送信内容を管理画面にも保存しておく理由
なぜ配布プラグインを使わずに自作したのか
オレオレナリでは、WordPress サイトを作るときに「プラグインをわざわざ入れなくても実装できるものは、プラグインを使わずに実装する」という方針にしています。
依存するプラグインは、少ないほどセキュリティ的にも運用的にも良い。テーマのコードで無理なく書けるものは、自分で書く。
お問い合わせフォームに必要な機能を書き出してみると、意外と多くありません。
- 項目の定義と入力チェック
- スパム対策
- 送信内容の保存
- 管理者への通知メールと、送信者への自動返信
このくらいなら自分で書けて、全部を把握できる量です。自作にすると、こんなメリットがあります。
| 観点 | 自作のメリット |
|---|---|
| 見た目 | HTML をテーマに直接書くので、デザインに合わせて自由に組める |
| 運用 | プラグインの更新作業や、WordPress 本体の更新に合わせた相性の確認が減る |
| 中身の把握 | どんな対策をしているか、どこに何が保存されるかを全部説明できる |
| 使い回し | プラグインのフォルダをコピーするだけで、ほかの案件でも使える(ビルドも不要) |
逆に、サイトを運用する人が管理画面で自由にフォームを組み立てたい場合は、Contact Form 7 のような配布プラグインのほうが向いています。誰がフォームを触るかで選ぶのがおすすめです。
項目と HTML はテーマ、処理はプラグインに分ける
自作のフォームは「NARI Form」というプラグインにまとめています。ただ、全部をプラグインに入れたわけではありません。分け方のルールはこうです。
| 置き場所 | 書くもの |
|---|---|
| プラグイン(どの案件でも同じ) | 受け付け・nonce・スパム対策・検証・保存・メール送信・リダイレクト |
| テーマ(案件ごとに違う) | 項目の定義・メールの文面・完了ページの URL・フォームの HTML |
項目や見た目は案件ごとに変わるので、プラグインには書きません。プラグインは「受け取って、確かめて、保存して、送る」だけに専念させると、ほかの案件にそのまま持っていけます。
項目はフィルターで定義する
テーマでは、nari_form_settings というフィルターで項目を定義します。実際のお問い合わせフォームから、一部の項目だけ抜き出すとこんな形です。
add_filter( 'nari_form_settings', function ( $settings ) {
$settings['forms']['contact'] = array(
'label' => 'お問い合わせ',
'thanks_url' => ore2nari_home_url( ORE_PAGE_SLUG_CONTACT, ORE_PAGE_SLUG_CONTACT_THANKS ),
'fields' => array(
'name' => array(
'label' => 'お名前',
'required' => true,
),
'email' => array(
'label' => 'メールアドレス',
'type' => 'email',
'required' => true,
),
'type' => array(
'label' => 'ご依頼の種類',
'type' => 'radio',
'required' => true,
'choices' => array( '新規制作', 'リニューアル', '改修・運用', 'その他' ),
),
'message' => array(
'label' => 'ご相談内容',
'type' => 'textarea',
'required' => true,
),
// …実際は会社名・予算・希望時期なども並ぶ
),
'auto_reply' => array( 'enabled' => true ),
);
return $settings;
} );
項目ごとに、種類(テキスト・メール・ラジオボタンなど)、必須かどうか、選択肢を書くだけです。選択肢にない値が送られてきたらエラーにする、といった検証はプラグイン側でまとめて行います。
HTML はテーマに自由に書く
フォームの HTML は、テーマのテンプレートに普通に書きます。プラグインが用意しているのは、name 属性や入力値、エラーを取り出すメソッドだけです。簡略化した例はこんな感じです。
<?php $form = Nari\Form\get_form( 'contact' ); ?>
<form method="post" action="#contact-form" id="contact-form">
<?php $form->hidden_fields(); ?>
<?php $form->form_error(); ?>
<label for="contact-name">お名前</label>
<input id="contact-name" type="text" name="<?php echo esc_attr( $form->name( 'name' ) ); ?>"
value="<?php echo esc_attr( $form->value( 'name' ) ); ?>" required<?php echo $form->aria( 'name' ); ?>>
<?php $form->error( 'name' ); ?>
<?php $form->turnstile(); ?>
<button type="submit">送信する</button>
</form>
action="#contact-form" にしておくと、エラーで表示し直したときにフォームの位置までスクロールしてくれます。地味ですが、長いページだと効きます。
送信の流れ
フォームは、表示しているページ自身に POST します。プラグインは template_redirect で受け取ります。
add_action( 'template_redirect', function () {
if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) || ! isset( $_POST['nari_form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
$form = get_form( sanitize_key( wp_unslash( $_POST['nari_form_id'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( null === $form ) {
return;
}
// POST の結果はキャッシュさせない
nocache_headers();
handle_submit( $form );
} );
受け取ったあとの流れはこうです。
エラーで表示し直したときに入力し直さなくて済むように、最初に入力値を取り出して持っておきます。
ハニーポット・nonce・送信までの時間・送信回数・入力チェック・Turnstile の順に確かめます。引っかかったら、エラーを付けてページをそのまま表示し直します。
送信内容を管理画面に保存し、管理者への通知と送信者への自動返信を送ります。
303 でリダイレクトします。完了ページで再読み込みしても、二重に送信されません。
入力欄の name 属性に name などをそのまま使うと、WordPress のクエリ変数とぶつかって 404 になります。NARI Form では、すべての入力欄を nari_form[項目名] という形で送るようにしています。
URL のパラメーターも同じです。制作実績のページから「この実績について問い合わせる」で移るとき、パラメーターを works にすると投稿タイプの名前とぶつかって 404 になるので、works_id にしています。
スパム対策は5段構え
フォームを公開すると、ロボットからの送信はほぼ確実に来ます。NARI Form では、5つの対策を重ねています。
| 対策 | 内容 |
|---|---|
| nonce | ほかのサイトから送信させる攻撃を防ぐ |
| 送信までの時間 | フォームを表示してから3秒未満の送信は受け付けない |
| ハニーポット | 人には見えない欄。入力されていればロボットとみなす |
| 送信回数 | 同じ IP アドレスから1時間に5回まで |
| Turnstile | Cloudflare のロボット判定。キーを設定したときだけ使う |
送信までの時間は、署名付きで送る
ロボットは、ページを開いた直後に送信してきます。そこで、フォームを表示した時刻を隠しフィールドで送り、送信までが短すぎたら受け付けないようにしています。
ただ、時刻をそのまま送ると、書き換えられたら意味がありません。なので、時刻に署名(wp_hash() で作ったハッシュ)を付けて送り、送信時に書き換えられていないかを確かめます。
function create_time_token( string $form_id ): string {
$time = time();
return $time . '.' . wp_hash( $time . '|' . $form_id, 'nonce' );
}
/**
* 表示してから送信までの時間が短すぎないか
*
* @param string $token create_time_token() で作ったトークン
* @param string $form_id フォーム ID
* @return bool 問題なければ true(トークンが壊れている・短すぎる場合は false)
*/
function check_time_token( string $token, string $form_id ): bool {
$parts = explode( '.', $token, 2 );
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
return false;
}
[ $time, $hash ] = $parts;
if ( ! hash_equals( wp_hash( $time . '|' . $form_id, 'nonce' ), $hash ) ) {
return false;
}
return time() - (int) $time >= (int) get_settings()['min_seconds'];
}
署名の比較には、=== ではなく hash_equals() を使っています。比べる時間の差から値を推測される攻撃(タイミング攻撃)を防ぐためです。
ハニーポットに引っかかったら、送ったふりをする
ハニーポットは、人には見えなくした入力欄です。ロボットは全部の欄を埋めがちなので、ここに入力があればロボットとみなします。
見えなくする方法は display: none ではなく、画面の外に出す形にしています。display: none の欄は入力しないロボットもいるからです。スクリーンリーダーで読み上げられないように aria-hidden、Tab キーで移動してこないように tabindex="-1" も付けています。
このとき、エラーを出さずに送信が完了したように見せて、何もしないのがポイントです。エラーを返すと、ロボットに「この欄が原因だ」と気づかれて、対策をすり抜けられてしまいます。
Turnstile は入力に問題がないときだけ確かめる
Cloudflare Turnstile のトークンは、1回しか使えません。入力エラーのたびに確かめると、そのたびにトークンを使い切ってしまいます。そこで、ほかのチェックをすべて通ったあと、最後に1回だけ確かめるようにしています。
送信回数の上限で、IP アドレスは保存している?
保存していません。IP アドレスはハッシュにして、送信回数を数えるトランジェント(一時的なデータ)のキーにだけ使っています。回数の期限は最初の送信から数えるので、送るたびに期限が延びることもありません。
送信内容は管理画面にも保存する
送信内容は、メールで送るだけでなく、管理画面の「フォーム送信」にも保存しています(非公開の投稿タイプとして保存)。
メールは、サーバーの設定や迷惑メールの判定しだいで、届かないことがあります。保存しておけば、メールが届かなくても問い合わせを取りこぼしません。メールを送れたかどうかも記録しているので、「届いていない」に気づけます。
サーバー標準の送信(sendmail)は、送信元のドメインを証明できず、迷惑メールに入りやすくなります。NARI Form では、wp-config.php に SMTP の設定を書いたときだけ SMTP に切り替えるようにしています。送信元のドメインには SPF・DKIM も設定しておきましょう。
ちなみに、送信者への自動返信には、お名前やお問い合わせ内容などの入力値を入れないようにしています。入れると、迷惑メールの「踏み台」に使われてしまうからです。
よくある疑問
ありません。入力 → 送信 → 完了ページ、の流れです。入力エラーがあったときは、入力値を残したまま同じページに表示し直します。
使えます。プラグインのフォルダをコピーして有効にし、テーマで項目と HTML を書けば動きます。PHP だけなので、ビルドも要りません。
ページのキャッシュが古くて nonce の有効期限が切れていた場合は、エラーを出してフォームを表示し直します。入力値は残るので、もう一度送信すれば通ります。
おわりに
フォームは「とりあえずプラグイン」で済ませがちですが、必要な機能を書き出してみると、自分で書けて全部を説明できる量でした。スパム対策も、一つひとつは小さな仕組みの積み重ねです。何をしているかを自分で説明できるフォームは、運用していても安心感が違うなと感じています。
フォームのプラグインを入れる前に、一度「必要な機能はどれだけか」を書き出してみるのもおすすめですよ。
WordPress のフォーム制作や改修のような制作・改修のご相談も受け付けています。お気軽にどうぞ。