XSS、CSRF、SQLインジェクションを分解し、htmlspecialchars、トークン、プリペアドステートメントが実際に何を防いでいるのかを正確に理解しましょう。
このレッスンを Kodokon で開くウェブの脆弱性の三大ファミリー、XSS、CSRF、インジェクションは、どれも同じ根本原因を共有しています。ユーザーから提供されたデータが、最終的にコード(HTML、SQL、あるいは正当なリクエスト)として解釈されてしまうのです。まずはXSSから始めましょう。$_GETの値をエスケープせずに表示すると、攻撃者は<script>タグを注入し、あなたの他の訪問者のブラウザでJavaScriptを実行できてしまいます。セッションを盗んだり、本人の知らないうちに操作を行ったりするのです。?comment=<script>alert(1)</script>とすると、下の最初の出力はスクリプトを実行してしまい、二番目はそれを無害化します。ここでは二つのフラグが重要です。ENT_QUOTESはシングルクォートも変換します(そうしないと、シングルクォートで囲まれた属性は注入可能なままになります)。そしてENT_SUBSTITUTEは、不正なUTF-8シーケンスを、空文字列を返す代わりに置き換えます。PHP 8.1以降、両方のフラグがようやくデフォルトになりましたが、明示的に書くことで意図が文書化され、古いバージョンで動くコードも守られます。
<?php
declare(strict_types=1);
$comment = $_GET['comment'] ?? '';
echo '<div>' . $comment . '</div>';
function e(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
echo '<div>' . e($comment) . '</div>';CSRFは一つの単純な事実を悪用します。ブラウザは、あなたのドメインへ向かうあらゆるリクエストに、たとえサードパーティのサイトから発生したものであっても、自動的にセッションクッキーを付けるのです。したがって悪意あるフォームは、被害者のセッションを使って/account/deleteを送信できてしまいます。古典的な防御策はシンクロナイザートークンです。セッションに保存されたランダムな秘密で、あらゆる変更を伴うリクエストで要求されます。サードパーティのサイトはそれを読むことも推測することもできないので、提供できません。
<?php
session_start();
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
http_response_code(403);
exit('Invalid CSRF token');
}
?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button>Delete my account</button>
</form>SQLインジェクションも同じパターンをたどります。ユーザーの値をクエリに連結するのは、ユーザーにSQLを書かせるのと同じことです。' OR 1=1 --はフィルターをテーブル全体へと変えてしまいます。解決策はエスケープではなくプリペアドステートメントです。クエリのテキスト(:emailプレースホルダー付き)と値は、別々にエンジンへ送られるので、エンジンはデータとコードを決して混同できません。専門家向けの細かい点を挙げると、MySQLではPDOはデフォルトでプリペアドステートメントをエミュレートし、クライアント側でエスケープします。本当にネイティブなプリペアドステートメントにするには、PDO::ATTR_EMULATE_PREPARESをfalseに設定しましょう。
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$email = $_GET['email'] ?? '';
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));