HTML エンティティのエスケープ規則:文脈ごとに必要な処理が違う理由
「< を < に置き換える」という処理は基本ですが、HTML を埋め込む文脈によって何をエスケープすべきかが変わります。本文と属性値、HTML と JavaScript、HTML と URL では別々のルールが要求されます。本記事ではこれらの使い分けと、エスケープ処理の罠を整理します。
5 つの基本エンティティ
HTML 仕様で定義された名前付きエンティティのうち、エスケープに使う最重要は 5 つ:
| 文字 | エンティティ | 数値参照 |
|---|---|---|
< | < | < |
> | > | > |
& | & | & |
" | " | " |
' | ' または ' | ' |
注:
'は HTML 4 ではなく XHTML / HTML5 のみ。古いブラウザ互換を考えるなら'が安全。
これら 5 つをエスケープすれば、HTML での XSS の大半は防げます。
文脈ごとのエスケープ規則
「どの文字をエスケープすべきか」は埋め込む文脈によって違います。
1. HTML 本文(要素内テキスト)
エスケープすべき文字:<、>、&
<p>2 < 3 && foo</p>
<!-- 表示: 2 < 3 && foo --> < と & を残すとパーサーが混乱します。> は仕様上必須ではありませんが、安全側で escape します。
2. HTML 属性値
エスケープすべき文字:<、>、&、"(または '、属性のクォートに合わせて)
<a title='hello "world"'>link</a> <a title="hello 'world'">link</a> 属性値内では引用符がエスケープ対象になります。< と > も含めるのが安全。
属性値を引用符で囲まない場合(例:<a title=hello>)は、空白・引用符・<>= などほぼすべてが特殊扱いされるので、必ず引用符で囲むのが原則。
3. JavaScript 文字列リテラル
<script>
const name = '<?= $name ?>'; // ❌ 危険
</script> ここでは HTML エスケープでは不十分。</script> という文字列が含まれていると、HTML パーサーがスクリプトの終了として解釈してしまいます:
<script>
const name = "</script><img src=x onerror=alert(1)>";
</script> スクリプトが途中で終了し、続く HTML が新しい要素として解釈されて XSS になります。
JavaScript 文字列に値を埋め込む場合の正攻法は:
- JSON エンコード + DOM 経由で受け取り:
<script>const name = JSON.parse(document.getElementById('data').textContent);</script>のように DOM を経由する - JSON.stringify で安全な JSON にする:ただし
<、>も<、>にエスケープする必要あり </script>の出現を防ぐ専用エンコード:<を<にエスケープ
簡単に言うと、JavaScript 文字列に未信頼データを埋め込む場合は HTML エスケープでは足りず、JS 用の追加エスケープが必要です。
4. URL の中
<a href="/search?q=<?= $query ?>">search</a> ここでは HTML エスケープと URL エンコードの両方が必要:
- まず URL エンコード(
encodeURIComponent) - その後 HTML 属性値としてエスケープ(
&を&に)
<a href="/search?q=hello+world&page=1">search</a> & を & にしないと、HTML パーサーが「&p から始まる文字エンティティ」として解釈しようとします。
5. CSS の中
<style>
.user-bg {
background: url('<?= $url ?>');
}
</style> CSS 文字列内では別のエスケープルール:' を \27 のように \HEX でエスケープ。HTML エスケープでは不十分。
CSS への動的埋め込みは元々リスクが高いので、避けられるなら避けるのが安全。
数値参照の 2 形式
数値文字参照には 10 進と 16 進の 2 形式があります:
& ← 10進、& (U+0026)
& ← 16進、& (U+0026) 両方の表記が混在していると検索や置換で取りこぼしが発生します。標準化するなら 10 進に統一するのが分かりやすい。
また、先頭ゼロを許容する仕様のため、& と & は同じ意味。攻撃者はこれを利用してフィルタを回避することがあります(例:ブラックリストで ' だけ弾くと ' で通る)。
二重エスケープと多重デコードの罠
二重エスケープ
エスケープ済みのデータを再エスケープすると、表示が壊れます:
入力: "Hello"
1回目: "Hello"
2回目: &quot;Hello&quot; ← 表示で「"Hello"」と見える DB → API → フロントエンドで各層がエスケープを試みると起こりがち。エスケープは出力直前の 1 回だけ が原則。
多重デコード
逆に、デコードを 2 回以上行うと攻撃の入口になります:
入力: &lt;script&gt;
1回目デコード: <script>
2回目デコード: <script> ← XSS 発火 ブラウザは HTML の解釈で 1 回だけデコードします。サーバー側で別途 1 回デコードしたデータをクライアントに渡すと、ブラウザでもう 1 回デコードされて「素の HTML」になります。
テンプレートエンジンに任せるのが原則
これらのルールを毎回手動で実装するのは事故の元です。実用上は:
- テンプレートエンジンの自動エスケープを ON にする(Jinja2 の
autoescape、ERB の<%= %>の自動エスケープ) - 生 HTML 出力(
{!! !!}や<%= raw %>)は本当に必要なときだけ - JS 用には JSON.stringify、URL 用には encodeURIComponent を経由する
- DOM 操作で値を入れるときは
textContentを使う(innerHTMLではなく)
テンプレートエンジンが文脈を判別して自動でエスケープしてくれるなら、その仕組みに任せるのが安全です。
まとめ:場面別チートシート
| 埋め込み先 | 必要な処理 |
|---|---|
| HTML 本文 | <、>、& を HTML エンティティに |
| HTML 属性値(クォート付き) | <、>、&、"(または ')を HTML エンティティに |
| JavaScript 文字列 | JSON.stringify + < > & を \u00XX に |
| URL のクエリ値 | encodeURIComponent + 結果に対して HTML 属性値エスケープ |
| CSS 文字列 | \HEX 形式の CSS エスケープ(リスク高、避ける) |
Web 開発では「未信頼データを最終的に埋め込む文脈は何か」を意識して、その文脈に合うエスケープを行うのが原則です。
特定の文字列を HTML エスケープしてみたい場面では、本サイトの HTML エンティティ変換ツールでエンコード・デコードを試せます。手動で書いた HTML が正しくエスケープされているか確認したいときに使えます。