■ CSRFとは
CSRF(クロスサイト・リクエスト・フォージェリ)とは、悪意のあるWEBサイトから標的のWEBサイトの機能を実行してユーザーの意図しない処理を実行させる攻撃です。
CSRFでは「リクエストの送信元を確認していない」という脆弱性が利用されています。
- あるシステム(標的)にログイン中のユーザーが悪意のあるWEBサイトを表示すると、不正なリクエストがログイン中のシステムに送信され、意図しない処理(ユーザー情報変更、大量の投稿など)が行われる
- ユーザーが悪意のあるWEBサイトを表示すると、脆弱性のあるお問い合わせフォームをもつWEBサイト(標的)に不正なリクエストが送信され、メールが送信されたり、身に覚えのないメールを受信する
CSRFの解説ではログイン機能が前提になっている例をよく見かけますが、お問合せなどログイン不要で利用できる機能も標的になります。
認証が必要なサイト/ページにのみCSRF対策が必要というわけではありません。認証の有無に関わらずCSRF対策は必要です。例えば、問い合わせフォームがCSRFに脆弱だと意味不明なデータを大量に送信されたりします。
https://gihyo.jp/dev/serial/01/php-security/0023
対策方法は以下の2つどちらか、または両方で対応します。
- 正規の送信元からCSRFトークンを送信してサーバー側で検証する
- セッションCookieのSameSite属性にSameSite=Lax または SameSite=Strict を設定する
SameSite属性によって、モダンなブラウザでは別ドメインからの悪意あるPOST送信(CSRF攻撃の基本手口)の際にCookieが送信されなくなります。
ただし、サブドメイン間での攻撃を防げないことや、ヒューマンエラーによってSameSite属性が書き換えられたり、ブラウザの仕様変更などを考慮してCSRFトークンによる検証も合わせます。
CSRFトークンは「オリジン(サブドメインを含む完全なURL)」またはシステムが発行した固有の値に紐づくため、こうしたサブドメイン間の攻撃(Related-Domain Attacker)も確実に防ぐことができます。
多層防御(Defense in Depth)の原則
セキュリティの鉄則として、「単一の防御メカニズムに依存しない(単一障害点を作らない)」という考え方があります。
ブラウザの仕様変更、未知のバグ、あるいは将来的に別のエンジニアが設定を誤って SameSite=None に書き換えてしまうといったヒューマンエラーが発生した場合でも、CSRFトークンが実装されていればシステムは守られます。
■ CSRF対策
CSRFトークンを利用する例
- サーバー側で作成したランダムな値をCSRFトークンとしセッション変数に保存する
- 画面側(入力、確認)にはhidden属性のinputを配置しCSRFトークンを設定する
- POST送信時に画面側からCSRFトークンを送信する
- サーバー側で同一CSRFトークンであることを検証(送信元が正しいことを検証)
■ コントローラー index.php
<?php
// --------------------------------------------------
// 初期化
// --------------------------------------------------
// セッション開始(GET送信時=画面初回表示時に実行)
// 必ずブラウザにHTMLが描画される前に実行する
// セッション IDが発行され、クッキー(PHPSESSID)としてクライアント側に保存される
session_start();
$errors = [];
$view = '';
// --------------------------------------------------
// 現在のステップを取得
// --------------------------------------------------
$step = $_POST['step'] ?? $_GET['step'] ?? 'input';
if (isset($_POST['back'])) {
$step = 'input';
}
// CSRFトークンの生成($_SESSIONに保存することでセッションが有効な間は保持できる)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// CSRFトークンの検証(POST送信時に検証)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $token)) {
$errors['token_error'] = '送信に失敗しました。時間をおいて再度お試しください。';
// トークンを再生成
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// 入力画面へ戻す
$step = 'input';
}
}
// --------------------------------------------------
// バリデーション
// --------------------------------------------------
if (empty($errors) && in_array($step, ['confirm', 'send'], true)) {
// validate関数が実装済みの想定
$errors = validate($_POST);
}
// --------------------------------------------------
// ステップ処理
// --------------------------------------------------
switch ($step) {
case 'confirm':
if (empty($errors)) {
$view = 'views/confirm.php';
} else {
// エラーがあれば入力画面へ
$view = 'views/form.php';
}
break;
case 'send':
// メール送信処理
// send関数が実装済みの想定
$sent = send($_POST);
if (!$sent) {
$errors['mail'] = 'メール送信に失敗しました。設定を確認してください。';
$view = 'views/form.php';
break;
}
// 二重送信防止のため完了画面へリダイレクト
header('Location: /contact/index.php?step=thanks');
exit;
case 'thanks':
$view = 'views/thanks.php';
break;
default:
$view = 'views/form.php';
break;
}
// --------------------------------------------------
// 描画
// --------------------------------------------------
// $errorsを画面で利用できるように展開する
extract($errors, EXTR_OVERWRITE);
// 共通ヘッダー
include 'views/parts/header.php';
// メインコンテンツ(各画面のbody部分のみを書いたファイル)
include $view;
// 共通フッター
include 'views/parts/footer.php';
?>
■ 入力画面 views/form.php
<h1>お問い合わせ</h1>
<?php if (!empty($errors['token_error'])): ?>
<p class="error"><?= h($errors['token_error']) ?></p>
<?php endif; ?>
<?php if (!empty($errors['mail'])): ?>
<p class="error"><?= h($errors['mail']) ?></p>
<?php endif; ?>
<form method="post">
<!-- hidden -->
<input
type="hidden"
name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>"
>
<input type="hidden" name="step" value="confirm">
<!-- 入力画面 -->
<div class="contact-form">
...省略
</div>
</form>
セッションCookieのSameSite属性を有効にする例
以前セッション管理について記事を書いたので、こちらの「セッションのセキュリティ」のセクションをご確認ください。
■ 参考サイト