HTMLエスケープとXSS対策の基本
「ユーザー名に <script> と入力されたらアラートが出た」—— XSS(クロスサイトスクリプティング)は、今も OWASP Top 10 に挙がり続ける Web 最頻出の脆弱性です。 対策の土台はシンプルで、ユーザー由来の文字列を HTML に埋め込むときに必ずエスケープすること。 ただし「どの5文字を」「どのコンテキストで」が曖昧なままだと漏れが生まれます。 本記事ではエスケープの基本から、React 時代の注意点、DOMPurify・CSP による多層防御までを整理します。
XSS remains one of the most common web vulnerabilities. The foundation is escaping user-supplied strings before inserting them into HTML — but which characters, and in which context, matters. This guide covers the 5 characters, context-aware escaping, React's auto-escaping and its escape hatches, DOMPurify, and CSP as defense-in-depth.
TL;DR
- エスケープすべきは
&<>"'の5文字。&を最初に変換 - DOM 操作は
textContentを使う。innerHTMLにユーザー入力を渡さない - React の
{}は自動エスケープ。ただしdangerouslySetInnerHTMLとjavascript:URL は別 - 属性値は必ず引用符で囲む(引用符なし属性はエスケープしても突破される)
- HTML を描画したいなら DOMPurify でサニタイズ。自作の置換は穴が出る
- CSP(
script-srcの制限)は「漏れたとき」の被害を抑える第二の防御線
1. エスケープすべき5文字 / The 5 characters
| 文字 | エンティティ | 放置した場合のリスク |
|---|---|---|
& | & | エンティティの誤解釈・二重エスケープの起点 |
< | < | タグ開始 = <script> や <img onerror> の注入 |
> | > | タグの終端操作 |
" | " | 属性値からの脱出(" onmouseover="...) |
' | ' | シングルクォート属性からの脱出 |
// 最小実装(順序が重要: & を最初に)
function escapeHtml(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}& の変換を最後にすると、変換済みの < が&lt; になる二重エスケープが起きます。順序は & が先です。 実際の攻撃でよく使われるのは <script> よりも<img src=x onerror="fetch(...)"> のようなイベントハンドラ属性で、< のエスケープだけでこの注入は無効化できます。
Escape & first or you will double-escape. Real-world payloads favor event handlers like img onerror over script tags — escaping < neutralizes them.
2. コンテキストが変わればルールも変わる / Context-aware escaping
HTMLエスケープは「HTML本文・引用符付き属性」向けのルールです。 別のコンテキストに同じ文字列を置くなら、別のエンコードが必要になります。
| 埋め込み先 | 必要な処理 | 典型的な失敗 |
|---|---|---|
| HTML本文 | HTMLエスケープ(5文字) | innerHTML に未処理文字列 |
| 属性値 | HTMLエスケープ + 必ず引用符で囲む | 引用符なし属性(value= 区切りで属性追加し放題) |
| URL(クエリ等) | encodeURIComponent | javascript:alert(1) を href にそのまま |
| JS文字列内 | JSON.stringify で埋め込み | </script> 文字列でスクリプト終端を突破される |
特に href / src は要注意です。エスケープしてもjavascript: スキームは文字として正当なので素通りします。 URL を受け取る場所では「http:/https: のみ許可」というスキームの許可リスト検証を入れてください。
Escaping rules depend on context: attribute values need quotes, URLs need encodeURIComponent plus a scheme allowlist (javascript: passes HTML escaping untouched), and strings inside scripts should be embedded via JSON.stringify.
3. React / モダンフレームワークでの注意点 / React and friends
// 安全: JSX の {} は自動エスケープされる
<p>{userInput}</p>
// 危険: HTML として解釈される
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// 危険: スキームは検証されない(React 19 は javascript: を警告/ブロックするが過信しない)
<a href={userInput}>link</a>
// HTML を描画したいなら sanitizer を通す
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }} />- JSX の
{}埋め込みはテキストノードとして描画されるため安全 dangerouslySetInnerHTMLは名前どおり危険。使うなら DOMPurify 等でサニタイズした結果だけを渡す- Markdown を HTML 化して表示する場合も同様(レンダラの「HTML許可」オプションに注意)
- 素のDOM操作では
element.textContent = userInputを使う。insertAdjacentHTML/document.writeは innerHTML と同類
JSX interpolation is safe by default. The escape hatches — dangerouslySetInnerHTML, raw hrefs, permissive Markdown renderers — are where XSS re-enters. Sanitize with DOMPurify when you must render HTML.
4. 多層防御: CSP / Defense in depth with CSP
エスケープが第一の防御なら、Content-Security-Policy は「漏れたときに実行させない」第二の防御です。
# インラインスクリプトを禁止し、自ドメイン + nonce のみ許可
Content-Security-Policy: script-src 'self' 'nonce-R4nd0m';
object-src 'none';
base-uri 'none';'unsafe-inline' を許可した CSP は XSS 防御としてはほぼ無力です。 nonce(リクエストごとのランダム値)または hash ベースで運用し、 まずは Content-Security-Policy-Report-Only で影響を観測してから強制に切り替えるのが 現実的な導入手順です。
A nonce- or hash-based script-src (never unsafe-inline) limits damage when an escaping bug ships. Roll out with Report-Only first.
まとめ
XSS対策の優先順位は「(1) 出力時に必ずエスケープ(コンテキスト別)→ (2) HTML描画はサニタイザ経由 → (3) CSPで保険」。 フレームワークの自動エスケープに乗りつつ、dangerouslySetInnerHTML と URL だけは 常に疑う——これだけで大半の XSS は防げます。 エスケープ結果の確認には HTML Entity Encoder を、エスケープ済みHTMLの整形確認にはHTML Formatter をどうぞ。
Escape on output per context, sanitize any HTML you must render, and back it all with a strict CSP. Stay suspicious of dangerouslySetInnerHTML and user-supplied URLs.