このサイトの問い合わせフォームは、フォーム系のプラグインを使っていません。素の <form method="post"> を置いて、テーマの中で受けています。JavaScript は1行も持っていません。
「プラグインを減らしましょう」という記事はよく見かけます。ただ、代わりに何を書くのかまで出ているものは少ないと感じていました。なので実装の全体像を置いておきます。
なぜプラグインを使わないのか
受け取りたい項目は、サイトのURL・メールアドレス・お名前・ご要望の4つだけです。この4項目のために Contact Form 7 を入れると、専用の CSS と JavaScript が読み込まれます。
気になるのは量より置き場所のほうです。このサイトはフォームをトップページの下部にも置いています。つまりいちばんスコアを守りたいページに、フォームのための資産が乗る。4項目に対しては割に合いません。
あと、このサイト自体が表示速度の改善を売っている、という事情もあります。プラグインを削りましょうと言っている側が、フォームのためにプラグインを入れていたら格好がつきません。
全体の形
やっていることは4つだけです。
template_redirectで POST を拾う- 値を検証して、非公開のカスタム投稿タイプに保存する
- 自分宛に通知メールを送る
- リダイレクトして
/contact/?sent=1に返す
4番目が PRG(Post/Redirect/Get)です。POST のまま画面を返すと、送信後にリロードされたときブラウザが再送信を確認してきます。リダイレクトを挟めば、リロードしても GET が繰り返されるだけになります。
function hw_handle_inquiry() {
if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
return;
}
if ( ! isset( $_POST['site_url'], $_POST['email'] ) ) {
return;
}
// … 検証・保存・通知 …
wp_safe_redirect( add_query_arg( 'sent', '1', home_url( '/contact/' ) ) );
exit;
}
add_action( 'template_redirect', 'hw_handle_inquiry' );フックは template_redirect です。init だと管理画面や REST の POST まで通ってしまって、条件を足すはめになります。template_redirect は公開側のリクエストが表示に進む直前なので、ここで止めて exit するのが素直でした。
nonce を置いていない理由
WordPress でフォームを書くと、まず wp_nonce_field() を置くよう言われます。このフォームには置いていません。ここは判断が分かれるところだと思うので、理由を2つとも書きます。
守る対象がない
nonce が防ぐのは CSRF、つまりログイン中のユーザーの権限を本人の意図しない形で使わせる攻撃です。守るべきものは「そのユーザーだからできる操作」であって、誰でもできる操作ではありません。
このフォームは未ログインの訪問者が使うもので、送信でできることは「問い合わせが1件増える」だけ。第三者のサイトから送信させたところで、その第三者が自分で送るのと変わりません。奪える権限がないので、CSRF が成立しません。
ページキャッシュと相性が悪い
こちらが実害のほうです。nonce には有効期限があって、既定では最大24時間で切れます。いっぽうページキャッシュは、HTML をそのまま保存して配ります。
何が起きるか。キャッシュされた HTML に古い nonce が焼き付いて、正当な訪問者の送信が弾かれます。やっかいなのは症状の出かたのほうです。自分でテストするときはキャッシュを消した直後に触るので、まず再現しません。壊れるのは、キャッシュが作られてから nonce の期限が切れるまで放置されたページを、たまたま開いた訪問者だけです。検証環境では通り、本番でだけ、しかも一部の人にだけ起きる。おまけに弾かれた側には「送信できませんでした」としか見えないので、わざわざ連絡はしてくれません。気づいたときには、何件取りこぼしたか分からない状態になっています。
効かない対策のために、問い合わせが静かに消える経路を作ることになる。それは割に合わないと判断しました。
前提
これは「ログイン後の操作を含まないフォーム」に限った話です。会員向けの機能や、権限によって結果が変わる送信なら nonce は要ります。そこは切り分けてください。
代わりに置いているもの
ハニーポットは display:none にしない
人間には見えず、機械には見える入力欄を置きます。埋まっていたら機械だと判断する。
<div class="p-form__honeypot" aria-hidden="true">
<label for="form-company">この欄は入力しないでください</label>
<input id="form-company" type="text" name="company" tabindex="-1" autocomplete="off">
</div>隠し方は CSS で画面外に飛ばします。display: none を使わないのは、その手口が知られていて、無視するボットがいるからです。tabindex="-1" と autocomplete="off" は、キーボード操作の人とブラウザの自動入力が誤って埋めてしまわないように付けています。
埋まっていた場合は、エラーを返さず成功したように見せて捨てます。失敗を返すと、ボット側に「この欄が判定に使われている」と教えることになるので。
レート制限は transient で足りる
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$key = 'hw_inquiry_' . md5( $ip );
if ( $ip && get_transient( $key ) ) {
wp_safe_redirect( add_query_arg( 'error', 'rate', $redirect ) );
exit;
}
// … 保存と通知が終わってから記録する …
set_transient( $key, 1, 60 );同一 IP から60秒に1回まで。IP をそのまま保存せず md5() を通しているのは、個人情報を残す量を減らすためです。照合にしか使わないので、元に戻せる必要がありません。
受け取った値の扱い
WordPress には用途別のサニタイズ関数が揃っているので、項目ごとに使い分けます。
| 項目 | 整形 | 検証 |
|---|---|---|
| サイトのURL | esc_url_raw() | wp_http_validate_url() |
| メールアドレス | sanitize_email() | is_email() |
| お名前 | sanitize_text_field() | — |
| ご要望 | sanitize_textarea_field() | — |
整形だけで済ませず、URL とメールは検証まで通す。整形は「危ない文字を落とす」処理であって、「正しい値かどうか」は見ていないため。
wp_unslash() を先に通すのを忘れないように。WordPress は $_POST にスラッシュを足した状態で渡してくるので、外さないと保存内容にバックスラッシュが混ざります。
保存先はカスタム投稿タイプにする
メール通知だけにすると、メールが届かなかったときに何も残りません。public => false のカスタム投稿タイプを1つ作って、そこに溜めています。管理画面の一覧に URL とメールを出しておけば、メールを見なくても状況が分かります。
登録時に 'capabilities' => array( 'create_posts' => 'do_not_allow' ) を付けて、管理画面から手で新規作成できないようにしました。受信箱に手書きの項目が混ざると、あとから見たときに区別がつかなくなるので。
この方法が向かない場合
いちばん引っかかるのは、クライアントが自分で項目を編集したい場合です。「電話番号の欄を足したい」と言われるたびにコードを書ける人間へ連絡が要る、という形は、渡し方として弱い。そこが見えているなら、最初からプラグインで組んで管理画面を渡したほうが、相手にとってもこちらにとっても楽です。軽さと引き換えに、相手の自由を減らしていることは自覚しておいたほうがいいと思っています。
あと2つは分かりやすい話で、条件分岐や確認画面が要るフォームと、フォームを頻繁に増やす運用。どちらも書くコストが釣り合いません。
逆に、項目が固定で、数が少なくて、置き場所がスコアに効くページなら、書いてしまったほうが軽く済みます。このサイトはその条件に当てはまっていました。