お問い合わせフォームに施す基本のセキュリティ対策3選(XSS・クリックジャック・CSRF)
🎯 対象読者
- PHPで入力→確認→完了の3画面フォームを作ったことがある方
- 「動くだけ」のフォームから一歩進んで、最低限のセキュリティ対策を知りたい方
- XSS・クリックジャッキング・CSRFという言葉は聞いたことがあるけど、実装イメージが湧かない方
📝 はじめに
PHPの練習として、入力画面→確認画面→完了画面を1ファイルで切り替える、よくある「お問い合わせフォーム」を作りました。
ただ、動くだけのフォームと、実務で使えるフォームの間には大きな差があります。それがセキュリティ対策です。今回は学習の一環として、以下の3つの代表的な攻撃と、その対策をフォームに組み込んでみました。実装しながらつまずいたポイントも合わせて、学びのログとして残します。
- Step1:XSS(クロスサイトスクリプティング)対策
- Step2:クリックジャッキング対策
- Step3:CSRF(クロスサイト・リクエスト・フォージェリ)対策
🏗️ フォームの前提構成
$pageFlagという変数1つで、入力(0)・確認(1)・完了(2)の3画面を切り替える構成にしています。
$pageFlag = 0;
if (!empty($_POST['btn_confirm'])) {
$pageFlag = 1;
}
if (!empty($_POST['btn_submit'])) {
$pageFlag = 2;
}
この土台の上に、3つの対策を1つずつ足していきます。
🛡️ Step1:XSS対策(サニタイズ関数)
XSSとは
ユーザーの入力値をそのまま画面に出力すると、入力の中に埋め込まれた<script>タグなどがHTMLとして解釈・実行されてしまう危険があります。これがXSS(クロスサイトスクリプティング)です。
例えば氏名の欄に以下のような文字列を入力されたとします。
<script>alert('やられた');</script>
エスケープ処理をしていないと、これがそのままJavaScriptとして実行されてしまいます。
対策:サニタイズ関数 h()
function h($str)
{
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
htmlspecialchars()は、HTMLで特別な意味を持つ文字(< > & " ')を、実行されない文字列(実体参照)に変換してくれる関数です。
| 引数 | 役割 |
|---|---|
$str |
変換したい文字列 |
ENT_QUOTES |
シングルクォート・ダブルクォート両方を変換対象にする |
'UTF-8' |
文字エンコーディングの指定(日本語を正しく扱うために必須) |
ENT_QUOTESを指定するのは、value="..."のようにダブルクォートで属性値を囲んでいるためです。指定しないとシングルクォートが変換されず、入力値によっては属性が壊れてしまう可能性があります。
適用ルール
ルールはシンプルです。
$_POSTの値をHTMLに出力する箇所は、必ずh()を通す
これは、以下のどちらのケースにも当てはまります。
-
value属性・hidden欄に出力するとき - 確認画面で値をそのまま表示するとき(
echo $_POST['your_name'];など)
💬 実装中のつまずきポイント:確認画面で「見せるため」に書く
echo $_POST['your_name'];のような箇所は、hidden欄と違って対策を忘れがちです。「表示するときは常にh()」を口癖のように意識すると漏れを防げます。
✅ Step1のポイント
- XSSは「入力値をそのままHTMLとして出力してしまう」ことで起きる
-
htmlspecialchars($str, ENT_QUOTES, 'UTF-8')で対策する - 出力箇所はすべて(value属性・hidden欄・確認画面の表示)漏れなく対応する
🖱️ Step2:クリックジャッキング対策
クリックジャッキングとは
攻撃者が自分のサイトに、対象サイトを**透明な<iframe>**で重ねて表示し、ユーザーには別のボタンに見せかけて、実際には透明なiframe越しに対象サイトのボタン(送信ボタンなど)を押させる攻撃です。
攻撃者のサイト
┌─────────────────────────┐
│ 「ここをクリック!」 │ ← ユーザーに見えているのはコレ
│ ┌───────────────────┐ │
│ │ 対象サイト(透明) │ │ ← 実際にクリックされているのはコレ
│ │ [送信する] │ │
│ └───────────────────┘ │
└─────────────────────────┘
対策:X-Frame-Optionsヘッダー
header("X-Frame-Options: DENY");
このHTTPレスポンスヘッダーで、「このページを<iframe>に埋め込んでいいか」をブラウザに指示できます。
| 値 | 意味 |
|---|---|
DENY |
どこからのiframe埋め込みも禁止 |
SAMEORIGIN |
同一オリジン内でのみiframe埋め込みを許可 |
お問い合わせフォームのように、単体で開かれることを想定していて他ページへの埋め込みが不要なページにはDENYが適しています。
実装上の注意点
header()関数はHTMLが1文字でも出力される前に呼ぶ必要があります。そのため、<?phpブロックの一番最初、$pageFlagの判定より前に置くのが安全です。これを守らないとheaders already sentという警告が出ます。
✅ Step2のポイント
- クリックジャッキングは「透明なiframe」でボタンを押させる攻撃
-
header("X-Frame-Options: DENY")で埋め込み自体を禁止する -
header()はHTML出力より前に書く
🔐 Step3:CSRF対策
CSRFとは
CSRF(クロスサイト・リクエスト・フォージェリ)は、ログイン中のユーザーに気づかれないまま、別サイト経由で本人の意図しないリクエストを送信させる攻撃です。
Cookie(セッションID)はブラウザが自動送信してしまうため、サーバー側だけでは「本人が本当に押したリクエストか」を区別できません。
対策の考え方:CSRFトークン
サーバー側だけが知っている予測不可能な値(トークン)を発行し、フォームに埋め込んでおきます。送信時に一致するか確認することで、外部のサイトはこの値を知りようがないため、なりすまし送信を防げます。
前提知識:$_SESSIONについて
CSRFトークンの実装には$_SESSIONの理解が欠かせません。$_POSTと比較すると違いがわかりやすいです。
$_POST |
$_SESSION |
|
|---|---|---|
| データの出どころ | ブラウザから送られてくる | サーバー側に保存されている |
| 生存期間 | 1回のリクエストのみ | 複数ページ間で持続 |
| ユーザーからの見え方 | HTMLソースに出る | サーバー内保存、ユーザーには見えない |
session_start()を呼ぶと、サーバー側に「そのユーザー専用の保管箱」が用意され、保管箱の番号(セッションID)だけがCookieとしてブラウザに渡されます。$_SESSIONの中身自体はブラウザに送られないという点が、CSRFトークンの安全性の土台になっています。
実装の4ステップ
① トークンの発行・保存(入力画面)
if (!isset($_SESSION['csrfToken'])) {
$csrfToken = bin2hex(random_bytes(32));
$_SESSION['csrfToken'] = $csrfToken;
}
$token = $_SESSION['csrfToken'];
random_bytes(32)で暗号学的に安全なランダム値を作り、bin2hex()でHTMLに埋め込める16進数の文字列に変換しています。
② フォームへの埋め込み
<input type="hidden" name="csrf" value="<?php echo $token; ?>">
③ 送信時の検証(確認画面・完了画面)
if (isset($_POST['csrf'], $_SESSION['csrfToken']) && $_POST['csrf'] === $_SESSION['csrfToken']) {
// 一致すれば処理を続ける
}
isset()を先に入れることで、フォームを経由せず直接アクセスされた場合など、$_POST['csrf']や$_SESSION['csrfToken']が存在しないケースでもUndefined array keyの警告を出さずに安全に「不一致」として扱えます。
④ 使用後の破棄(完了画面)
unset($_SESSION['csrfToken']);
送信が完了したらトークンを削除し、同じトークンを使った再送(リプレイ)を防ぎます。
さらに安全にするための改善ポイント
実装しながら気づいた、より丁寧にするならこうした方がいい点です。
1. ===よりhash_equals()を使う
トークンの比較には、比較にかかる時間を一定にして「タイミング攻撃」のリスクを避けられるhash_equals()が推奨されます。isset()と組み合わせるとこうなります。
if (isset($_POST['csrf'], $_SESSION['csrfToken']) && hash_equals($_SESSION['csrfToken'], $_POST['csrf'])) {
// OK
}
2. hidden欄のnameは全画面で統一する
入力画面・確認画面それぞれでhiddenのnameが食い違っていると、比較対象の値が常に空になり、対策が機能しなくなります(実装中、実際にこれでハマりました)。csrfという名前をすべての画面で統一することが重要です。
✅ Step3のポイント
- CSRFは「別サイト経由でなりすましリクエストを送らせる」攻撃
-
$_SESSIONにだけ保存されたトークンと、フォームのhidden欄の値を照合する - 比較には
isset()+hash_equals()を使い、nameは画面間で統一する
📋 まとめ
| 対策 | 防ぐ攻撃 | 使った関数・ヘッダー |
|---|---|---|
| Step1 | XSS(悪意あるスクリプトの実行) | htmlspecialchars() |
| Step2 | クリックジャッキング(透明iframeでのクリック誘導) | header("X-Frame-Options: DENY") |
| Step3 | CSRF(意図しないリクエストの送信) |
session_start() + random_bytes() + hash_equals()
|
どれも「ユーザーの入力・リクエストを信用しすぎない」という共通の考え方に基づいています。今回は学習用のシンプルなフォームでしたが、実務でフォームを作る際にも、この3点セットは基本として押さえておきたいポイントだと感じました。
次はSQLインジェクション対策やバリデーション(入力値チェック)についても、同じ形で整理していきたいと思います。