【PHP入門】安全なフォーム処理 入力検証・エスケープ・CSRF対策を学ぼう

PHPでフォームを受け取れるようになったら、次に必要なのが安全な入力処理です。利用者から届く値は、未入力、想定外の形式、改ざんされた値を含む可能性があります。

この記事では、入力値を信用しないという基本から、入力検証、HTMLエスケープ、CSRF対策、二重送信を防ぐPRGまでを、問い合わせフォームのコードで学びます。フォーム送信の基本は、先に$_GETと$_POSTの使い方で確認してください。

フォーム処理で守る3つのポイント

  1. 入力検証:必須、文字数、形式などが条件を満たすか調べる
  2. 出力時エスケープ:HTMLへ表示する直前に特殊文字を変換する
  3. CSRF対策:利用者が意図しない送信を受け付けない

この3つは役割が異なります。入力を検証したからといって、HTMLへ安全に表示できるとは限りません。

入力値は必ずサーバー側で検証する

HTMLのrequiredmaxlengthは入力を助けますが、ブラウザ以外から送信すれば回避できます。最終的な検証はPHP側で行います。

<?php
$name = trim((string) ($_POST["name"] ?? ""));
$errors = [];

if ($name === "") {
    $errors["name"] = "名前を入力してください";
} elseif (mb_strlen($name) > 50) {
    $errors["name"] = "名前は50文字以内で入力してください";
}

trim()で前後の空白を除き、空文字と文字数を調べています。日本語の文字数にはmb_strlen()を使います。

メールアドレスの形式を検証する

filter_var()FILTER_VALIDATE_EMAILを使うと、メールアドレスの基本形式を検証できます。

<?php
$email = trim((string) ($_POST["email"] ?? ""));

if ($email === "") {
    $errors["email"] = "メールアドレスを入力してください";
} elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    $errors["email"] = "メールアドレスの形式を確認してください";
}

形式が正しくても、実在するメールアドレスとは限りません。本人確認が必要なサービスでは、確認メールなど別の仕組みも必要です。

検証と無害化を混同しない

処理目的
検証条件を満たすか判定するFILTER_VALIDATE_EMAIL
正規化比較しやすい形へ整えるtrim()
エスケープ出力先で命令として解釈されない形にするhtmlspecialchars()

FILTER_SANITIZE_STRINGはPHP 8.1以降で非推奨です。「危険そうな文字を削れば安全」と考えず、用途ごとに検証し、出力先に合わせてエスケープします。

HTMLへ表示するときはエスケープする

入力値をそのままHTMLへ出すと、タグやスクリプトとして解釈される可能性があります。HTMLへ出す直前にhtmlspecialchars()を使います。

<?php
function escape(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );
}

$name = "<script>alert('x')</script>";
echo escape($name);

ブラウザには入力文字列が文字として表示され、スクリプトとしては実行されません。データベースへ保存する前にHTMLエスケープするのではなく、HTMLへ出力する時点で行うのが基本です。

CSRFとは

CSRF(クロスサイト・リクエスト・フォージェリ)は、ログイン中の利用者に、本人が意図しない送信を行わせる攻撃です。フォームへ予測できないトークンを埋め込み、セッションの値と一致するか確認します。

トークンを作ってフォームへ埋め込む

<?php
session_start();

if (!isset($_SESSION["csrf_token"])) {
    $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}

$csrfToken = $_SESSION["csrf_token"];
?>

<form method="post">
    <input
        type="hidden"
        name="csrf_token"
        value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, "UTF-8") ?>"
    >
    <button type="submit">送信</button>
</form>

送信時にhash_equals()で比較する

<?php
$sessionToken = $_SESSION["csrf_token"] ?? "";
$postedToken = (string) ($_POST["csrf_token"] ?? "");

if ($sessionToken === "" || !hash_equals($sessionToken, $postedToken)) {
    http_response_code(403);
    exit("不正なリクエストです");
}

===ではなくhash_equals()を使うと、比較にかかる時間の差を利用した攻撃を避けやすくなります。

POSTリクエストだけを処理する

登録・変更・削除の処理では、リクエストメソッドを確認します。

<?php
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    header("Allow: POST");
    exit("POSTで送信してください");
}

URLを開くだけのGETリクエストで、データを削除するような設計は避けます。

完成例:安全な問い合わせフォーム

次は、入力検証、エスケープ、CSRF、PRGを一つにしたcontact.phpです。

<?php
declare(strict_types=1);

session_start();

function escape(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );
}

if (!isset($_SESSION["csrf_token"])) {
    $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}

$name = "";
$email = "";
$message = "";
$errors = [];
$successMessage = $_SESSION["success"] ?? null;
unset($_SESSION["success"]);

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $postedToken = (string) ($_POST["csrf_token"] ?? "");

    if (!hash_equals($_SESSION["csrf_token"], $postedToken)) {
        http_response_code(403);
        exit("不正なリクエストです");
    }

    $name = trim((string) ($_POST["name"] ?? ""));
    $email = trim((string) ($_POST["email"] ?? ""));
    $message = trim((string) ($_POST["message"] ?? ""));

    if ($name === "") {
        $errors["name"] = "名前を入力してください";
    } elseif (mb_strlen($name) > 50) {
        $errors["name"] = "名前は50文字以内で入力してください";
    }

    if ($email === "") {
        $errors["email"] = "メールアドレスを入力してください";
    } elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $errors["email"] = "メールアドレスの形式を確認してください";
    }

    if ($message === "") {
        $errors["message"] = "問い合わせ内容を入力してください";
    } elseif (mb_strlen($message) > 1000) {
        $errors["message"] = "問い合わせ内容は1000文字以内で入力してください";
    }

    if ($errors === []) {
        // ここでメール送信やデータベース保存を行う
        $_SESSION["success"] = "問い合わせを受け付けました";
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));

        header("Location: contact.php", true, 303);
        exit;
    }
}
?>
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>問い合わせ</title>
</head>
<body>
    <h1>問い合わせ</h1>

    <?php if ($successMessage !== null): ?>
        <p><?= escape($successMessage) ?></p>
    <?php endif; ?>

    <form method="post" action="contact.php">
        <input type="hidden" name="csrf_token"
               value="<?= escape($_SESSION["csrf_token"]) ?>">

        <div>
            <label for="name">名前</label>
            <input id="name" name="name" value="<?= escape($name) ?>">
            <?php if (isset($errors["name"])): ?>
                <p><?= escape($errors["name"]) ?></p>
            <?php endif; ?>
        </div>

        <div>
            <label for="email">メールアドレス</label>
            <input id="email" name="email" type="email"
                   value="<?= escape($email) ?>">
            <?php if (isset($errors["email"])): ?>
                <p><?= escape($errors["email"]) ?></p>
            <?php endif; ?>
        </div>

        <div>
            <label for="message">問い合わせ内容</label>
            <textarea id="message" name="message"><?= escape($message) ?></textarea>
            <?php if (isset($errors["message"])): ?>
                <p><?= escape($errors["message"]) ?></p>
            <?php endif; ?>
        </div>

        <button type="submit">送信</button>
    </form>
</body>
</html>

PRGで再読み込みによる二重送信を防ぐ

POST処理が成功したあとに303でGETページへリダイレクトする流れを、Post/Redirect/Get(PRG)と呼びます。完了画面を再読み込みしても、同じPOSTデータが再送信されにくくなります。

データベースとパスワードの注意

  • SQLには文字列連結ではなくPDOのプリペアドステートメントを使う
  • パスワードは平文保存せずpassword_hash()でハッシュ化する
  • セッションCookieにはHTTPS、HttpOnly、適切なSameSiteを設定する
  • 秘密情報や例外の詳細を利用者向け画面へ表示しない

練習問題

完成例に「件名」を追加し、未入力と100文字超過をサーバー側で検証してください。再表示する値とエラーメッセージには必ずescape()を使います。

まとめ

  • ブラウザ側の制約だけに頼らず、PHP側で検証する
  • HTMLへ値を出す直前にhtmlspecialchars()を使う
  • 状態を変更するフォームにはCSRFトークンを付ける
  • 成功後はPRGでGETページへ移動する

次は、同じ安全対策を使いながらデータを保存します。PDOとMySQLでCRUDを作る方法へ進みましょう。

PHP学習ロードマップへ戻る

関連記事

コメント

この記事へのコメントはありません。