PHPお問い合わせフォームのセキュリティ改善完全ガイド|バリデーション・サニタイズ・CSRF対策を実装する


はじめに

前回の記事では実際のPHPコードを読み解きながら、$_POST の受け取り方・トークン生成・mb_send_mail() によるメール送信の仕組みを解説しました。その中で「バリデーション・サニタイズ・CSRFトークンの照合処理が不足している」という点を指摘しました。今回はその3つのセキュリティ上の不足を実際に補強した改善コードを解説します。学習のアウトプットとして、「動くコードをより安全にする」視点でまとめます。


1. 前回のコードで不足していた3つのポイント

前回の振り返りとして、改善が必要だった点を整理します。

問題点リスク対策
バリデーション未実装空のフォームや不正な形式の値が送信されるサーバー側でも必須チェック・形式チェックを行う
サニタイズ未実装XSS(クロスサイトスクリプティング)攻撃を受ける出力前に htmlspecialchars() で特殊文字を無害化する
CSRFトークンの照合なし悪意ある第三者がなりすまして送信できる$_SESSION['token']$_POST['token'] を照合する

2. 改善したコードの全体像

今回の改善版では以下の流れで処理を組み直しています。

1. セッションを開始する
2. CSRFトークンを生成してセッションに保存する
3. POSTデータを受け取ってバリデーション(入力チェック)をかける
4. エラーがあれば処理を止めてエラーメッセージを表示する
5. 全項目が問題なければサニタイズ(無害化)してメール本文を組み立てる
6. CSRFトークンを照合してから送信処理を実行する
7. 2通のメールを送信して結果を表示する
8. 成功したらセッションを破棄する

3. CSRFトークンの生成と照合

3.1 トークンをHTMLフォームに埋め込む

前回のコードはトークンを生成してセッションに保存するところで止まっていました。このトークンをHTMLフォームの隠しフィールドにも出力する必要があります。

フォーム側のHTML(index.php または contact.php):

<?php
session_start();

// トークンを生成してセッションに保存する
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['token'] = $token;
?>

<form action="send.php" method="post" id="mail_form">

    <!-- CSRFトークンを隠しフィールドに埋め込む -->
    <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">

    <!-- 以下、通常のフォーム項目 -->
    <input type="text" name="company" placeholder="会社名">
    <!-- ... -->

</form>

3.2 送信処理側でトークンを照合する

<?php
session_start();

// ① CSRFトークンの照合
// セッションのトークンとPOSTで送られたトークンを比較する
if (
    !isset($_SESSION['token']) ||
    !isset($_POST['token'])    ||
    $_SESSION['token'] !== $_POST['token']
) {
    // 一致しない場合は不正アクセスとみなして処理を止める
    http_response_code(403);
    die('不正なアクセスです。フォームから送信してください。');
}

// 照合が終わったらセッションのトークンはすぐに削除する
// (同じトークンを使い回せないようにする)
unset($_SESSION['token']);

💡 トークンは照合後すぐに unset() で削除しましょう: 照合が終わったトークンをそのまま残しておくと、そのトークンを使って再送信できてしまいます。使い捨てにすることでより安全になります。


4. バリデーション(入力チェック)の実装

4.1 バリデーションとは

バリデーションとは、フォームから送られてきた値が「正しい形式かどうか」を確認する処理です。JavaScriptでも第二十四弾で実装しましたが、JavaScriptは無効化できるためサーバー側でも必ず行う必要があります。

4.2 バリデーション処理の実装

<?php
// ② バリデーション(入力チェック)

$errors = []; // エラーメッセージを格納する配列

// POSTデータが送られてきたか確認
if (!isset($_POST['company'])) {
    die('不正なアクセスです。');
}

// 必須項目のチェック
if (empty(trim($_POST['name_1']))) {
    $errors['name_1'] = '姓を入力してください。';
}

if (empty(trim($_POST['name_2']))) {
    $errors['name_2'] = '名を入力してください。';
}

if (empty(trim($_POST['mail_address']))) {
    $errors['mail_address'] = 'メールアドレスを入力してください。';
} elseif (!filter_var(trim($_POST['mail_address']), FILTER_VALIDATE_EMAIL)) {
    // メールアドレスの形式チェック
    $errors['mail_address'] = '正しいメールアドレスの形式で入力してください。';
}

if (empty(trim($_POST['mail_contents']))) {
    $errors['mail_contents'] = 'お問い合わせ内容を入力してください。';
}

// お問い合わせの種類(チェックボックス)のチェック
if (!isset($_POST['kind']) || !is_array($_POST['kind']) || count($_POST['kind']) === 0) {
    $errors['kind'] = 'お問い合わせの種類を1つ以上選択してください。';
}

// 文字数の上限チェック
if (mb_strlen($_POST['mail_contents']) > 2000) {
    $errors['mail_contents'] = 'お問い合わせ内容は2000文字以内で入力してください。';
}

// エラーがあれば処理を止める
if (!empty($errors)) {
    // エラーを表示する処理(フォームに戻るなど)
    foreach ($errors as $field => $error) {
        echo '<p class="error">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</p>';
    }
    exit;
}

4.3 使用した関数の解説

関数役割
empty(trim($_POST['name_1']))前後の空白を除いて空かどうかを確認する
filter_var($email, FILTER_VALIDATE_EMAIL)メールアドレスとして正しい形式かを確認する
mb_strlen($str)日本語を含む文字列の文字数を正確にカウントする
is_array($_POST['kind'])チェックボックスの値が配列で届いているかを確認する
count($_POST['kind']) === 0配列の要素数が0(何も選択されていない)かを確認する

💡 trim() を必ずかけましょう: スペースだけ入力された場合を empty() で検知するために、先に trim() で前後の空白を取り除いてからチェックします。empty(" ")false(空でないと判定)になりますが、empty(trim(" "))true(空と判定)になります。


5. サニタイズ(無害化)の実装

5.1 サニタイズとは

サニタイズとは、ユーザーが入力した値に含まれる特殊文字を無害な文字列に変換する処理です。HTMLに出力するときに <script> タグなどが実行されないよう無害化します。

5.2 htmlspecialchars()の使い方

// ③ サニタイズ(出力直前に必ずhtmlspecialchars()を通す)

// ❌ 危険な書き方(XSS脆弱性あり)
echo $_POST['company'];

// ✅ 安全な書き方
echo htmlspecialchars($_POST['company'], ENT_QUOTES, 'UTF-8');

// ポイント:第2引数はENT_QUOTES(シングルクォートとダブルクォートの両方を変換)
// 第3引数は文字コード(UTF-8を明示する)

5.3 サニタイズ済みの値を変数に格納する

バリデーションが通ったあと、メール送信とHTML出力の両方で使うために、サニタイズ済みの値を変数に格納しておきます。

// バリデーションが通った後でサニタイズを行う
function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

// 各フィールドをサニタイズして変数に格納する
$company  = h(trim($_POST['company']));
$name1    = h(trim($_POST['name_1']));
$name2    = h(trim($_POST['name_2']));
$read1    = h(trim($_POST['read_1']));
$read2    = h(trim($_POST['read_2']));
$email    = h(trim($_POST['mail_address']));
$phone    = h(trim($_POST['phone']));
$contents = h(trim($_POST['mail_contents']));

// チェックボックスのサニタイズ
$kind_array = array_map(function($item) {
    return htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
}, $_POST['kind']);
$kind = implode('、', $kind_array);

💡 h() という短い関数名でラップするのは実務でよく使うテクニックです: 毎回 htmlspecialchars($str, ENT_QUOTES, 'UTF-8') と書くのは長くて間違いが起きやすいため、h() という短い関数にまとめておくとスッキリ書けます。

5.4 メール本文にはhtmlspecialcharsは不要

// メールの本文はHTMLではなくプレーンテキストなので
// htmlspecialcharsは不要(かけると < が &lt; などに化けてしまう)

// ただしメールヘッダーに使う値は別途確認が必要(後述)
$contents_mail = <<<EOD
              ◆会社名
              {$company}

              ◆お名前
              {$name1} {$name2}
              EOD;

⚠️ サニタイズした値をメール本文に使うと文字化けする場合があります: htmlspecialchars() をかけると <&lt; に変換されます。プレーンテキストのメール本文には不向きです。メール送信用とHTML表示用で変数を分けるか、html_entity_decode() で戻してから使う方法があります。


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

6.1 メールヘッダーインジェクションとは

メールヘッダーインジェクションとは、ユーザーの入力値に改行コードを含ませることでメールのヘッダーに不正な情報を注入する攻撃です。

例えばメールアドレスとして以下のような値が送られた場合:

example@example.com\r\nBcc:spam@example.com

こうなると Bcc: ヘッダーが追加されてスパムメールの踏み台になる

6.2 対策の実装

// メールアドレスから改行コードを除去する
function sanitize_email_header($str) {
    // 改行コード(CR・LF)を削除する
    return str_replace(array("\r", "\n"), '', $str);
}

// さらにfilter_varで形式チェックも行う
$email_raw = trim($_POST['mail_address']);

if (!filter_var($email_raw, FILTER_VALIDATE_EMAIL)) {
    die('メールアドレスの形式が正しくありません。');
}

// 形式チェックが通ったものをヘッダー用にも安全化する
$email_safe = sanitize_email_header($email_raw);

// ヘッダーに使う
$from = "Return-Path: " . $to . "\r\n";
$from = $from . 'From: ' . $email_safe;

7. 改善版コードの完成形

上記の改善をすべて組み込んだ send.php の完成形です。

<?php
session_start();

// ===== ① CSRFトークンの照合 =====
if (
    !isset($_SESSION['token'])             ||
    !isset($_POST['token'])                ||
    !hash_equals($_SESSION['token'], $_POST['token'])
) {
    http_response_code(403);
    die('不正なアクセスです。フォームから送信してください。');
}
unset($_SESSION['token']);

// ===== ② バリデーション =====
$errors = [];

if (!isset($_POST['company'])) {
    die('不正なアクセスです。');
}

if (empty(trim($_POST['name_1']))) {
    $errors['name_1'] = '姓を入力してください。';
}
if (empty(trim($_POST['name_2']))) {
    $errors['name_2'] = '名を入力してください。';
}

$email_raw = trim($_POST['mail_address'] ?? '');
if (empty($email_raw)) {
    $errors['mail_address'] = 'メールアドレスを入力してください。';
} elseif (!filter_var($email_raw, FILTER_VALIDATE_EMAIL)) {
    $errors['mail_address'] = '正しいメールアドレスの形式で入力してください。';
}

if (empty(trim($_POST['mail_contents'] ?? ''))) {
    $errors['mail_contents'] = 'お問い合わせ内容を入力してください。';
} elseif (mb_strlen($_POST['mail_contents']) > 2000) {
    $errors['mail_contents'] = 'お問い合わせ内容は2000文字以内で入力してください。';
}

if (!isset($_POST['kind']) || !is_array($_POST['kind']) || count($_POST['kind']) === 0) {
    $errors['kind'] = 'お問い合わせの種類を選択してください。';
}

if (!empty($errors)) {
    foreach ($errors as $error) {
        echo '<p class="error">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</p>';
    }
    exit;
}

// ===== ③ サニタイズ =====
function h($str) {
    return htmlspecialchars((string)$str, ENT_QUOTES, 'UTF-8');
}

function sanitize_header($str) {
    return str_replace(["\r", "\n"], '', $str);
}

$company  = h(trim($_POST['company']));
$name1    = h(trim($_POST['name_1']));
$name2    = h(trim($_POST['name_2']));
$read1    = h(trim($_POST['read_1']));
$read2    = h(trim($_POST['read_2']));
$email    = sanitize_header($email_raw);
$phone    = h(trim($_POST['phone']));
$contents = h(trim($_POST['mail_contents']));

$kind_array = array_map(fn($item) => h($item), $_POST['kind']);
$kind       = implode('、', $kind_array);

// ===== ④ メール送信 =====
mb_language("Japanese");
mb_internal_encoding("UTF-8");

$to       = "(運営者のメールアドレス)";
$param    = "-f" . $to;

// 自分宛てメールの本文
$mail_contents = <<<EOD
◆会社名
{$company}

◆お名前
{$name1} {$name2}

◆ふりがな
{$read1} {$read2}

◆メールアドレス
{$email}

◆電話
{$phone}

◆お問い合わせの種類
{$kind}

◆内容
{$contents}
EOD;

$mailtitle = "{$name1} {$name2}様よりお問い合わせが届きました。";
$from      = "Return-Path: " . $to . "\r\n";
$from      .= "From: " . $email;

// ユーザー宛て自動返信メールの本文
$to2        = $email;
$mailtitle2 = "【自動送信】受付を完了いたしました。";
$contents2  = <<<EOD
お問い合わせありがとうございます。
以下の内容を送信いたしました。
必ず返信いたしますのでしばらくお待ちください。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

{$mail_contents}

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
E-mail: (運営者のメールアドレス)
サイト運営者:(会社名)
EOD;

$from2  = "Return-Path: " . $to . "\r\n";
$from2 .= "From: " . $to;

// ユーザー宛て送信
if (mb_send_mail($to2, $mailtitle2, $contents2, $from2, $param)) {

    $message = '<p class="question-text">『' . h($email) . '』宛に確認メールを送信しました。<br>お問い合わせありがとうございます。</p>';

    // 自分宛て送信
    if (mb_send_mail($to, $mailtitle, $mail_contents, $from, $param)) {

        // ===== ⑤ セッション破棄 =====
        $_SESSION = [];
        if (isset($_COOKIE[session_name()])) {
            $params = session_get_cookie_params();
            setcookie(
                session_name(), '',
                time() - 42000,
                $params["path"],
                $params["domain"],
                $params["secure"],
                $params["httponly"]
            );
        }
        session_destroy();

    } else {
        $message = '<p class="question-text error">送信エラーが発生しました。しばらく待ってから再度送信してください。</p>';
    }

} else {
    $message = '<p class="question-text error">『' . h($email) . '』宛に確認メールを送信できませんでした。<br>正しいメールアドレスで再度ご連絡をお願いいたします。</p>';
}
?>

8. 前回からの改善点まとめ

改善項目前回のコード改善後のコード
CSRFトークン照合生成のみ・照合なしhash_equals() で安全に照合
必須チェックなしempty(trim()) で全必須項目をチェック
メール形式チェックなしfilter_var(FILTER_VALIDATE_EMAIL)
文字数制限なしmb_strlen() で2000文字以内をチェック
XSS対策なしhtmlspecialchars() / h() 関数
ヘッダーインジェクション対策なしstr_replace() で改行コードを除去
トークン使い捨てなしunset($_SESSION['token']) で即削除
CSRFトークン比較関数=== 演算子hash_equals() でタイミング攻撃対策

💡 hash_equals() を使う理由: === での文字列比較はタイミングによって処理時間がわずかに変わり、それを悪用してトークンを推測する攻撃(タイミング攻撃)が存在します。hash_equals() は常に一定の処理時間で比較するため、この攻撃に強い安全な関数です。


9. 実装チェックリスト

CSRFトークンをHTMLフォームの隠しフィールドに出力しているか

送信処理の先頭でトークンを hash_equals() で照合しているか

照合後すぐに unset($_SESSION['token']) でトークンを削除しているか

必須項目に empty(trim()) でチェックをかけているか

メールアドレスに filter_var(FILTER_VALIDATE_EMAIL) をかけているか

文字数の上限を mb_strlen() で確認しているか

HTML出力前に htmlspecialchars(ENT_QUOTES, UTF-8) をかけているか

メールヘッダーに使う値の改行コードを str_replace() で除去しているか

エラーが1つでもあれば exit で処理を止めているか

メール本文用とHTML出力用の変数を混同していないか


まとめ

今回は前回のコードで不足していたバリデーション・サニタイズ・CSRF対策を補強した改善版を解説しました。ポイントをまとめると:

  • CSRFトークンはHTMLフォームに隠しフィールドで出力し、送信時に hash_equals() で照合・使い捨てにする
  • バリデーションはサーバー側でも必ず行い、必須チェック・形式チェック・文字数制限をすべて実装する
  • サニタイズは htmlspecialchars(ENT_QUOTES, UTF-8) をHTML出力の直前に必ずかける
  • h() という短い関数にまとめると書き忘れが減って実用的
  • メールヘッダーに使うメールアドレスは str_replace() で改行コードを除去する
  • メール本文(プレーンテキスト)と HTML出力では使うべき処理が異なることを意識する

「動くコードを作る」だけでなく「安全に動くコードにする」という視点を持てるようになることが、フリーランスや実務でも信頼されるエンジニアへの一歩だと感じています。この連載を通じて学んできたHTML・CSS・JavaScript・PHPの知識が、少しずつつながってきました。次の記事では引き続き実践的なテーマをお届けします。お楽しみに!

コメント

タイトルとURLをコピーしました