1. 今回の学習ゴールと練習課題(仕様)
あなたは会員制サイトの「ニックネーム変更画面」を任されました。ところが先輩から「この画面、CSRF対策が入ってないよ。罠サイトを踏んだだけでニックネームを勝手に書き換えられるよ」と指摘されてしまいます。
CSRF(クロスサイト・リクエスト・フォージェリ)とは、ログイン中のユーザーに罠サイト経由で意図しないPOSTを送らせる攻撃です。ブラウザはCookie(セッションID)を自動で送ってしまうため、サーバー側は「本人の操作」と「罠からの送信」を見分けられません。そこで、正規のフォームにだけ埋め込まれた推測不能な合言葉(CSRFトークン)で正当な送信かを照合します。
課題の仕様
- 画面表示(GET)時、
random_bytes()で推測不能なトークンを生成してセッションに保存し、hiddenフィールドに埋め込む - 送信(POST)時、送られたトークンとセッションのトークンを
hash_equals()で厳格に比較する - トークンが無い・一致しない場合は HTTPステータス400 を返して即時遮断(Fail-Fast)し、更新処理を一切行わない
- ニックネームは前後の空白(全角・半角)を除いて1〜20文字。成功したらトークンを破棄し、303リダイレクト(PRGパターン)で完了メッセージを1回だけ表示する
- 画面に出す値はすべて
htmlspecialchars()でエスケープし、XSSを防ぐ
期待される実行結果
# 正規のフォームから「kixking」と送信した場合
ニックネームを変更しました。
現在のニックネーム:kixking
# トークン無しで直接POSTされた場合(罠サイトからの攻撃を想定)
$ curl -i -X POST -d "nickname=hacker" http://localhost:8000/nickname.php
HTTP/1.1 400 Bad Request
Connection: close
Content-Type: text/plain; charset=UTF-8
不正なリクエストです。画面を再読み込みしてからやり直してください。
2. 初心者がハマるNGコード(Before)と解説
「トークンを作って比較すればいいんでしょ?」と書くと、だいたいこうなります。僕も新人時代にやらかしました。
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// NG①: 存在チェックなし + 緩い比較(==)
if ($_POST['token'] == $_SESSION['token']) {
// NG②: 入力値を検証せずに保存(全角空白や配列がすり抜ける)
$_SESSION['nickname'] = $_POST['nickname'];
// NG③: エスケープせずに出力(XSS)
echo 'ニックネームを ' . $_POST['nickname'] . ' に変更しました';
} else {
echo '不正なリクエストです';
}
}
// NG④: 推測可能なトークン & 毎回無条件で上書き
$_SESSION['token'] = md5(uniqid());
?>
<form method="post">
<input type="hidden" name="token" value="<?php echo $_SESSION['token']; ?>">
<input type="text" name="nickname">
<button>変更</button>
</form>
どこがダメなのか:4つのセキュリティ落とし穴
- NG①:
null == nullで素通りする(型ジャグリングと存在チェック漏れ)
トークン無しで直接POSTされると$_POST['token']は存在せず、PHP 8ではWarning: Undefined array key "token"を出しつつ値はnullになります。さらに「ログイン済みだがまだこのフォームを開いたことがない」ユーザーの場合、セッション側もnullです。結果としてnull == nullが true となり、防御が完全に素通りします。また==は型変換が働くため、文字列と数値の比較で意図せぬ一致が起きるリスクもあります。 - NG②:入力値が無検証
空文字や1万文字の巨大文字列、配列(nickname[]=x)まで保存されてしまいます。また通常のtrim()だけでは全角スペース(\u{3000})を除去できず、全角スペースのみの無効なニックネームが保存される恐れがあります。 - NG③:エスケープ漏れによるXSS(クロスサイト・スクリプティング)
<script>を含む悪意ある文字列がそのままブラウザで実行されます。XSSが成立すると、JavaScript経由でセッションCookieやDOM内の正規CSRFトークンが盗まれるため、CSRF対策もすべて無効化されます。 - NG④:
md5(uniqid())は推測可能 & 複数タブで壊れるuniqid()は現在時刻(マイクロ秒)ベースの値であり、暗号論的に安全な乱数ではありません。MD5でハッシュ化しても生成元が推測できれば突破されます。また、画面を開くたびに無条件でセッションのトークンを上書きすると、別タブで開いた瞬間に古いタブからの送信がすべて失敗する原因になります。
3. コピペで即動く模範解答コード(After)
最新のPHP 8.3 / 8.4で動作確認済みです。nickname.php として保存し、同じフォルダで php -S localhost:8000 を実行して http://localhost:8000/nickname.php をブラウザで開いてください。
<?php
declare(strict_types=1);
// ===== 設定値(マジックナンバーは定数化) =====
const CSRF_SESSION_KEY = 'csrf_token';
const CSRF_TOKEN_BYTES = 32; // 32バイト = 16進数で64文字
const NICKNAME_MAX_LENGTH = 20;
// ===== セキュリティヘッダー(クリックジャッキング・MIMEスニッフィング対策) =====
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
// ===== セッション開始(二重開始を防ぎ、Cookie属性を安全側に倒す) =====
if (session_status() === PHP_SESSION_NONE) {
session_start([
'cookie_httponly' => true, // JavaScriptからCookieを読ませない(XSS時の漏洩防止)
'cookie_samesite' => 'Lax', // 他サイトからのPOSTにCookieを付けない(多層防御)
'use_strict_mode' => true, // 未知のセッションIDを受け付けない(セッション固定化対策)
// 'cookie_secure' => true, // 本番(HTTPS)環境では必ず true に設定する
]);
}
/** HTMLエスケープの短縮関数 */
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* 前後の空白を除去する(PHP 8.4 の mb_trim() 互換・全角空白対応)
*/
function trimWhitespace(string $value): string
{
// PHP 8.4以降はネイティブの mb_trim() を使用
if (function_exists('mb_trim')) {
return mb_trim($value);
}
// PHP 8.3以前への互換性: 全角スペース(\u{3000})を含めた前後の空白を除去
return preg_replace('/\A[\p{Z}\s]+|[\p{Z}\s]+\z/u', '', $value) ?? '';
}
/** CSRFトークンを取得する(なければ暗号論的乱数で新規発行) */
function issueCsrfToken(): string
{
$token = $_SESSION[CSRF_SESSION_KEY] ?? null;
if (!is_string($token) || $token === '') {
$token = bin2hex(random_bytes(CSRF_TOKEN_BYTES));
$_SESSION[CSRF_SESSION_KEY] = $token;
}
return $token;
}
/** 送信されたトークンがセッションのトークンと一致するか検証する */
function isValidCsrfToken(mixed $submittedToken): bool
{
$sessionToken = $_SESSION[CSRF_SESSION_KEY] ?? null;
if (!is_string($sessionToken) || $sessionToken === '') {
return false; // サーバー側に「正解」がない = 必ず拒否
}
if (!is_string($submittedToken) || $submittedToken === '') {
return false; // 未送信・配列送信などを拒否
}
// 第1引数: 正解(サーバー側)、第2引数: ユーザー入力(タイミング攻撃を防ぐ)
return hash_equals($sessionToken, $submittedToken);
}
/** 使い終わったトークンを破棄する(次の画面表示で新しいトークンを発行) */
function consumeCsrfToken(): void
{
unset($_SESSION[CSRF_SESSION_KEY]);
}
// ===== リクエスト処理 =====
$errors = [];
$inputNickname = '';
$isPost = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
if ($isPost) {
// 【重要】CSRF検証失敗時はFail-Fast(即時遮断)。攻撃リクエストにフォームを再描画しない
if (!isValidCsrfToken($_POST['csrf_token'] ?? null)) {
http_response_code(400);
error_log('[WARN] CSRF token validation failed on nickname form');
header('Content-Type: text/plain; charset=UTF-8');
echo "不正なリクエストです。画面を再読み込みしてからやり直してください。\n";
exit;
}
$rawNickname = $_POST['nickname'] ?? '';
$inputNickname = is_string($rawNickname) ? trimWhitespace($rawNickname) : '';
$length = mb_strlen($inputNickname);
if ($length === 0 || $length > NICKNAME_MAX_LENGTH) {
$errors[] = sprintf('ニックネームは1〜%d文字で入力してください。', NICKNAME_MAX_LENGTH);
} else {
$_SESSION['nickname'] = $inputNickname;
consumeCsrfToken();
$_SESSION['flash'] = 'ニックネームを変更しました。';
// PRGパターン: 303で同じ画面へGETリダイレクト(F5での再送信・二重更新を防ぐ)
$redirectUrl = $_SERVER['SCRIPT_NAME'] ?? ('./' . basename(__FILE__));
header('Location: ' . $redirectUrl, true, 303);
exit;
}
}
// ===== 画面表示用データ =====
$csrfToken = issueCsrfToken();
$currentNickname = $_SESSION['nickname'] ?? '未設定';
$flashMessage = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']); // フラッシュメッセージは1回表示したら消す
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ニックネーム変更</title>
</head>
<body>
<h1>ニックネーム変更</h1>
<?php if (is_string($flashMessage)): ?>
<p style="color: green;"><?= h($flashMessage) ?></p>
<?php endif; ?>
<?php foreach ($errors as $error): ?>
<p style="color: red;"><?= h($error) ?></p>
<?php endforeach; ?>
<p>現在のニックネーム:<?= h($currentNickname) ?></p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<label>
新しいニックネーム:
<input type="text" name="nickname" maxlength="<?= NICKNAME_MAX_LENGTH ?>" value="<?= h($inputNickname) ?>">
</label>
<button type="submit">変更する</button>
</form>
</body>
</html>
4. 現場のプロ(kixking)による徹底コード解説
Step 1:トークンは random_bytes() + bin2hex() で作る
random_bytes(32) はOSが提供する暗号論的擬似乱数生成器(CSPRNG)から32バイト(256ビット)の乱数列を取得します。これを bin2hex() で64文字の16進数文字列に変換することで、HTMLのhidden属性にも安全に埋め込めます。rand()・mt_rand()・uniqid() は「ゲームのサイコロやテスト用の一意ID」のための関数であり、予測可能なためセキュリティトークンに使うのは厳禁です。
【最新PHP仕様メモ】 PHP 8.2からオブジェクト指向の乱数APIRandom\Randomizerが導入され、PHP 8.3ではgetBytesFromString()も追加されました。ただし、CSRFトークンのような16進数文字列の発行には、軽量でPHP 7系から互換性のあるbin2hex(random_bytes(32))が現在も現場で最も広く使われています。なお、ログイン処理を実装する際は、セッション固定化攻撃(Session Fixation)を防ぐために必ずsession_regenerate_id(true)を呼んでセッションIDを更新してください。
Step 2:検証は「型チェック → 空チェック → hash_equals」の3段構え
isValidCsrfToken() は、まずサーバー側に正解が無ければ問答無用で拒否します。これでNG①の null == null 問題が根絶されます。次に送信値が文字列でなければ拒否。csrf_token[]=x のように配列が送りつけられても、後続の比較に渡る前に安全に弾きます。
最後の照合に === ではなく hash_equals() を使うのはタイミング攻撃(Timing Attack)対策です。通常の文字列比較(===)は先頭から1文字ずつ比べ、不一致が見つかった瞬間に処理を打ち切る(短絡評価)ため、サーバーの応答時間のナノ秒単位の差からトークンが1文字ずつ推測される脆弱性があります。hash_equals() は長さが一致していれば常に一定時間(Constant-time)で比較を行います。引数は「第1引数=正解(Known string)、第2引数=ユーザー入力(User string)」の順で渡すのが公式のお作法です(PHP 8.2以降、どちらかに文字列以外を渡すと TypeError が発生するため、事前の is_string() 検証が必須です)。
Step 3:CSRF失敗時は「Fail-Fast(即時遮断)」が鉄則
ここが多くの初級者がバリデーションと混同しやすいポイントです。「ニックネームが21文字以上だった」という入力ミスは、フォームを再描画してエラー文と入力値を戻すのが親切です。しかし、CSRFトークンの不一致はユーザーの入力ミスではなく、攻撃または重大なセッション異常です。
そのため、通常のフォーム再描画を行わずに http_response_code(400) を設定し、メッセージを出力して即座に exit; で処理を中断します。攻撃リクエストに対して無駄にHTMLを描画したり、新しいトークンを発行して返したりしない(Fail-Fastの原則)ことが、サーバー資源の保護やクリックジャッキング等の複合攻撃の抑止につながります。また、ログ出力(error_log)の際も、トークンの生値そのものはログに残さないのがセキュリティ監査上の重要ルールです。
Step 4:成功したらトークン破棄 → 303リダイレクト(PRGパターン)
更新に成功したら consumeCsrfToken() でトークンを破棄します。これにより、同じトークンの使い回し(リプレイ攻撃)を防げます。続けて 303 See Other で同じ画面へGETリダイレクトするPRG(Post/Redirect/Get)パターンを適用しています。完了メッセージはセッションに入れて1回表示したら unset する「フラッシュメッセージ」パターンを採用しています。
Step 5:多層防御(SameSite・X-Frame-Options・PHP 8.4のmb_trim)
現代のWebアプリケーションでは、単一の対策だけでなく多層防御(Defense in Depth)を敷くのがプロのスタンダードです。
- SameSite=Lax は「保険」であって「本命」ではない:ブラウザの
SameSite=Lax属性により他サイトからのPOSTにCookieが付与されにくくなっていますが、古いブラウザやトップレベルナビゲーション(リンク遷移)、同一eTLD+1内の別サブドメインからの送信には無力です。トークン検証が主防壁、SameSiteは保険です。 - クリックジャッキング対策(
X-Frame-Options: DENY):悪意あるサイトが<iframe>であなたのフォームを透明にして重ね、ユーザーにクリックさせる攻撃を防ぐため、iframeへの埋め込みを拒否するヘッダーを送出しています。 - 【PHP 8.4新機能】
mb_trim()と全角空白の罠:従来のPHPのtrim()は半角スペースや改行文字しか除去できず、全角スペース(\u{3000})のみのニックネームがすり抜けて保存されるという日本のWeb開発特有の落とし穴がありました。PHP 8.4で追加されたmb_trim()(および互換正規表現)を使用することで、全角・半角スペースが混在した入力も安全に正規化しています。
現場メモ:LaravelやSymfonyなどのフレームワークでは、フォーム内に@csrfや{{ csrf_field() }}を書くだけでこの仕組みが自動で組み込まれます。しかし、内部の仕組み(セッション照合・Fail-Fast・タイミング攻撃対策)を理解していないと、本番環境で「419 Page Expired」エラーが多発した際に真の原因(複数タブ、キャッシュ設定、Cookieドメイン不一致など)を特定できません。素のPHPで一度書き切った経験が、必ず武器になります。
5. もう一歩進める!発展チャレンジ課題
模範解答が動いたら、次の実践的な発展要件に挑戦してみてください。
- トークンに有効期限を付ける:セッションに
['value' => $token, 'issued_at' => time()]の連想配列で保存し、発行から30分(定数化すること)を経過したトークンは400で弾くように改造してみましょう。 - 複数フォームに対応する:「ニックネーム変更」と「パスワード変更」の2画面を作り、
$_SESSION['csrf_tokens'][$formName]のようにフォームごとに独立したトークンを管理してみましょう。 - 複数タブ問題(ワンタイム vs セッション固定)のトレードオフを考える:送信のたびにトークンを破棄する「完全ワンタイム方式」は最も強固ですが、複数タブで開いた際に古いタブがエラーになるUX低下があります。一方「セッション中は同じトークンを維持する」方式は複数タブでも快適に動作します。金融系などの高重要度処理と通常フォームでの使い分けを検討してみましょう。
- Origin / Referer ヘッダの追加検証:
$_SERVER['HTTP_ORIGIN']または$_SERVER['HTTP_REFERER']を取得し、自サイトのホストと一致しているかをPOST時に検証する多層防御を追加してみましょう。
まとめ
CSRF対策の本質は「推測できない合言葉を発行し、セッションと厳密に照合し、失敗時は即時遮断(Fail-Fast)し、成功したら破棄してPRGで戻る」という一連の規律にあります。random_bytes() による暗号論的乱数、hash_equals() によるタイミング攻撃対策、そしてPHP 8.4の mb_trim() やセキュリティヘッダーによる多層防御。この基本構造を一度マスターしておけば、どんなフレームワークや言語に移っても安全なWebアプリケーションを自信を持って設計・実装できます。