XSSの流れと応用的な対策:HTML/CSS埋め込みの実務ルール
要点:HTML/CSSを“文字列連結で作らない”。許可リスト+文脈別エスケープ+安全API+CSPの多層防御で、埋め込み需要と安全性を両立する。
1. 何が難しいのか(応用編の前提)
- 埋め込み対象が HTML本文 / 属性 / URL / CSS / JS文字列 / SVG と複数の文脈に跨り、各文脈で必要な防御が異なる。
- リッチテキスト・Markdown・WYSIWYGの普及により、「一部のタグだけ通したい」 ニーズが増加。
- ブラウザは壊れたマークアップも寛容に補正するため、少しの抜けが実行に直結。
[未信頼入力] → [型/長さ/スキーム検証] → [サニタイズ(必要最小限)]
↓ ↓
[出力文脈判定] → [文脈別エスケープ] → [安全APIでDOM構築]
↓
[CSP/HttpOnlyで最終防御]
2. HTMLタグを「一部許可」する設計方針
2.1 許可リスト(allowlist)を基本に
-
許可タグは最小限:
p, br, strong, em, ul, ol, li, code, pre, a, imgなど。 -
禁止タグ(代表):
script, style, iframe, object, embed, link, base, form, meta, svg。 -
禁止属性:
on*(全イベント),style,srcset,formaction,integrity,xlink:hrefなど。 -
URL属性(
href,src等)は スキーム許可リスト(https://のみ等)。javascript:/data:は拒否。
2.2 サニタイズとエスケープの違い(比較)
| 目的 | サニタイズ | エスケープ |
|---|---|---|
| 何をする | 危険な要素/属性を削除・置換 | 危険文字をエンコード(<→<) |
| いつ使う | 「一部HTMLを通す」時 | すべての出力(文脈ごと) |
| 失敗時の挙動 | 表示崩れ/情報欠落の可能性 | ただのテキスト表示になる |
| 注意点 | 許可範囲の設計が難しい | 文脈(HTML/属性/JS/CSS)ごとにルールが違う |
原則:まずエスケープ、やむを得ない箇所だけサニタイズ+許可リスト。
3. 安全なDOM組み立てパターン
3.1 文字列でDOMを作らない
NG
div.innerHTML = userHtml; // 直描画はXSSの温床
OK
const p = document.createElement('p');
p.textContent = userText; // テキストとして表示
container.appendChild(p);
3.2 属性はAPIで設定
const a = document.createElement('a');
const url = userHref; // 未信頼
if (/^https:\/\//.test(url)) {
a.setAttribute('href', url);
}
a.textContent = 'リンク';
// target利用時はタブなりすまし対策
a.rel = 'noopener noreferrer';
a.target = '_blank';
3.3 画像はプレースホルダと組み合わせる
const img = document.createElement('img');
const src = userSrc;
img.src = /^https:\/\//.test(src) ? src : '/img/placeholder.png';
img.alt = '';
SVGは原則禁止(
<script>や外部参照が混ざりやすい)。必要な場合はサーバ側で画像化(PNG/SVGサニタイズ済みなど)。
4. CSSを扱うときの注意点
4.1 ユーザー入力でCSSを生成しない
- クラス割当で表現(列挙型にマップ)。
- インラインstyleは数値/列挙のみ許可(単位・範囲検証)。
-
url()に未信頼入力を入れない。@importや外部参照を禁止。
NG
el.className = 'badge badge--' + userColor; // 直連結
el.style.cssText = 'background:' + userInput; // 値検証なし
OK
const MAP = { primary:'badge--primary', success:'badge--success', warning:'badge--warning' };
el.classList.add('badge', MAP[userColor] ?? 'badge--primary');
const size = Number(userSize);
if (Number.isFinite(size) && size >= 8 && size <= 48) {
el.style.fontSize = `${size}px`;
}
4.2 セレクタを動的生成する場合
-
CSS.escape()をセレクタ名にのみ使用(値のエスケープではない)。 - カスタムプロパティ(
--var)に未信頼文字列を入れない。
4.3 CSPでstyleも締める
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-<RANDOM>';
style-src 'self' 'nonce-<RANDOM>';
object-src 'none'; base-uri 'self'; frame-ancestors 'self';
style-src 'unsafe-inline'は極力避ける。どうしても必要なら nonce 方式 へ移行。
5. よくあるアンチパターンと対策(HTML/CSS)
| アンチパターン | 何が起きる | 対策 |
|---|---|---|
innerHTML 直描画 |
任意タグ/イベント挿入 | テキスト表示 or 許可リスト+サニタイズ+安全API |
| 属性の非クォート | 属性分断→onerror注入 |
すべて"…"で囲み、値はエスケープ |
href="javascript:..." |
即時実行 | スキーム許可(https:// 等)で検証 |
srcset の野放し |
外部トラッキング/実行経路 |
srcのみに限定 or ホスト固定 |
style許可 |
CSS経由の抜け道/外部参照 | 原則禁止。必要時は数値/列挙のみ |
| 動的クラス直連結 | クラス名汚染 | サーバ定義の列挙にマップ |
| SVG許可 | スクリプト混入 | 禁止 or 画像化/厳格サニタイズ |
target=_blankのみ |
window.opener 悪用 |
rel="noopener noreferrer" 併用 |
6. ユースケース別レシピ
6.1 Markdownビューア(コード含む)
-
方針:Markdown→HTML 変換後、許可タグ/属性でサニタイズ。リンクは
https://のみ、画像は プロキシ配信(同一オリジン化)。 - コードブロックはテキストとして出力(ハイライトはサーバ/ワーカー側で安全に)。
疑似コード
const html = renderMarkdown(md);
const safe = sanitize(html, {
tags: ['p','br','em','strong','ul','ol','li','code','pre','a'],
attrs: { a:['href','rel','target'] },
url: (u) => /^https:\/\//.test(u)
});
root.innerHTML = safe; // ※サニタイズ済みのみ
6.2 WYSIWYG(リッチテキスト)
- 入力段階で貼り付けサニタイズ。保存前にもサーバで再サニタイズ(ダブルチェック)。
- 画像/ファイルは自社ストレージへアップロード→発行URLに差し替え。
6.3 メールテンプレート編集(HTML許可)
- 許可タグは極小。
style/script/form系は不可。 - 変数埋め込みは テンプレートエンジンの自動エスケープ を使用。
raw/safeフラグは禁止。
7. サーバサイドの責務(最終ライン)
-
文字コード固定:
Content-Type: text/html; charset=UTF-8。 -
Cookie保護:
HttpOnly; Secure; SameSite。 -
レスポンスヘッダ:CSP(
script-src/style-srcにnonce)、X-Content-Type-Options: nosniff。 - 画像/SVGの取り扱い:外部URL直参照を避け、画像プロキシやサーバ変換(SVG→PNG)。
8. 自動テストに組み込む(最小セット)
- ペイロード例:
"><img src=x onerror=alert(1)>
" onmouseover="alert(1)
javascript:alert(1)
<data:text/html,><svg/onload=alert(1)>
</script><script>alert(1)</script>
- E2Eで「表示→無害なテキストとして出ること」「リンクがhttpsのみ」「
target=_blankにrel付与」を検証。 - CSP有効時、インラインスクリプトがブロックされることを確認(nonceなしで失敗するのが正)。
9. まとめ(応用編チェックリスト)
- まずテキスト表示。HTML/CSSを通す箇所は最小限に限定
- 許可タグ/属性の明文化とユニットテスト
-
URLはスキーム許可。
javascript:/data:を拒否 - 属性値はエスケープ+必ずクォートで囲む
-
DOM構築は
createElement/textContent/setAttribute - CSSはクラス列挙にマップ。インラインは数値/列挙のみ
- SVGは原則禁止(必要時はサーバ変換)
-
target=_blankにはrel="noopener noreferrer" -
CSPで
script-src/style-srcを nonce 運用 - 代表ペイロードでE2E自動化
付録図:HTML/CSS埋め込みの意思決定フロー
[未信頼データを表示したい]
│
┌── テキスト表示で良い? ────────┐
│ はい │ いいえ
▼ ▼
textContentで出力 [HTMLを通す必要がある]
│
┌── 許可タグ/属性は定義済み? ───────┐
│ はい │ いいえ
▼ ▼
サニタイズ→安全API組立 設計→許可リスト作成→テスト
│
▼
CSP/HttpOnlyで最終防御
この応用編を既存の「基本編」の基準に統合し、レビュー/テストの共通チェックリストとして運用すれば、HTML/CSS埋め込みを伴う機能でも堅牢性を維持できます。