PHPでフォームを受け取れるようになったら、次に必要なのが安全な入力処理です。利用者から届く値は、未入力、想定外の形式、改ざんされた値を含む可能性があります。
この記事では、入力値を信用しないという基本から、入力検証、HTMLエスケープ、CSRF対策、二重送信を防ぐPRGまでを、問い合わせフォームのコードで学びます。フォーム送信の基本は、先に$_GETと$_POSTの使い方で確認してください。
フォーム処理で守る3つのポイント
- 入力検証:必須、文字数、形式などが条件を満たすか調べる
- 出力時エスケープ:HTMLへ表示する直前に特殊文字を変換する
- CSRF対策:利用者が意図しない送信を受け付けない
この3つは役割が異なります。入力を検証したからといって、HTMLへ安全に表示できるとは限りません。
入力値は必ずサーバー側で検証する
HTMLのrequiredやmaxlengthは入力を助けますが、ブラウザ以外から送信すれば回避できます。最終的な検証は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を作る方法へ進みましょう。
コメント