HTMLエスケープは「特殊文字をエンティティに置換するだけ」の単純処理に見えて、replace を並べる順番を間違えると壊れる。< を先に処理すると二重エスケープが起きる、' は ' で書いてはいけない、といった地味な罠がある。ぱんだツールズのHTMLエスケープ/アンエスケープは、この5文字の変換と復元をブラウザ内で確認できるツール。
この記事では、HTMLエスケープの実装で踏みやすい「処理順序」と「' の表記」の2つの罠を、実装ベースで書く。
エスケープする5文字
HTMLのテキストとして安全に出すためにエスケープするのは、次の5文字。
| 文字 | エンティティ | 理由 |
|---|---|---|
& |
& |
エンティティの開始文字そのもの |
< |
< |
タグの開始 |
> |
> |
タグの終了 |
" |
" |
属性値の囲み(ダブルクォート) |
' |
' |
属性値の囲み(シングルクォート) |
日本語などASCII範囲外の文字はエスケープしない。あくまでHTMLの構文上意味を持つ記号だけを潰す。実装は素朴な replace の連鎖だが、この5行の順番に意味がある。
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&') // ← 必ず最初
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
罠1:「&」を最初に変換しないと二重エスケープになる
& の置換を最初にやるのが絶対条件。理由は、他の4つのエンティティがすべて & で始まるから。
仮に順番を入れ替えて、< を先に処理したとする。入力 < は < になる。ところがこの < には & が含まれている。続けて &→& の置換を回すと、さっき自分が作った < の & まで巻き込まれて &lt; になってしまう。
入力: <
悪い順(< を先に):
.replace(/</, '<') → <
.replace(/&/, '&') → &lt; ❌ 二重エスケープ
ブラウザはこれを <(文字列)と表示してしまい、< に戻らない。
& を最初に処理すれば、この時点で本文中に生の & しか存在しないので、安全に全部 & へ潰せる。その後に < > " ' を変換しても、新しく生まれる < などの & はもう置換対象として残っていない(& の replace は終わっている)。
入力: <
正しい順(& を先に):
.replace(/&/, '&') → < (& が無いので変化なし)
.replace(/</, '<') → < ✅
A & B <tag> のような混在入力で違いがはっきり出る。& を最初に処理すれば A & B <tag> と正しく変換されるが、順番を崩すと既存エンティティの & を二度打ちして表示が壊れる。「エンティティの開始文字を最初に潰す」——これが順序依存の本質。
アンエスケープは順序が逆で、& を最後に処理する
面白いのは、デコード側(アンエスケープ)ではこの順序がひっくり返ること。実装では < > " ' ' ' を先に全部戻してから、最後に & → & を処理する。
function unescapeHtml(text: string): string {
return text
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, '&') // ← 必ず最後
}
理由はエスケープと対称で、すでにエスケープ済みの文字列を、もう一段だけエスケープした入力(< を再エスケープした &lt; など)を正しく1段階だけ戻すため。もし & を最初に戻すと、&lt; はまず & + lt; = < になり、続く < → < の置換でさらに1段階余計にデコードされて < まで戻ってしまう。& を最後に回せば、他のエンティティのパターンマッチが先に完了しているので、&lt; は <(1段階だけ戻った状態)で止まる。エスケープは & が先、アンエスケープは & が後——エンティティの開始文字を「新しく作り出す前に潰す」か「他を全部処理し終えるまで残す」かの違いが、逆順という形で現れる。
罠2:シングルクォートは ' で書かない
もう1つの罠が '(シングルクォート)の表記。上の実装では ' を '(数値文字参照) に変換している。' という名前付きエンティティも存在するのに、なぜ使わないのか。
' はXMLとHTML5で定義されたエンティティで、HTML4など古い仕様では未定義。レガシーなHTMLパーサやメールクライアントに ' を食わせると、そのまま ' という文字列として表示されてしまう環境がある。一方、数値文字参照の '(' のコードポイント39を直接指定)は、仕様やパーサの世代に関係なくどこでも ' として解釈される。**「名前付きより数値参照のほうが移植性が高い」**ので、エスケープ出力では ' を採用する。
面白いのは、逆方向のアンエスケープでは複数の表記を寛容に受けること。
function unescapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'") // 16進の数値参照
.replace(/'/g, "'") // 名前付き
}
' に戻せる表記が3つある。'(10進数値参照)、'(16進数値参照。39 = 0x27)、'(名前付き)。どれも世の中のHTMLで実際に見かけるので、出力するときは最も安全な ' に寄せつつ、戻すときは他ツールが吐いた表記も受け付けるという非対称にしてある。エスケープは1通りに正規化、アンエスケープは寛容に、というのは変換系ツールの定石。
このツールでできること・できないこと
この5文字エスケープが守るのは、HTMLのテキストノードや二重引用符で囲んだ属性値というコンテキスト。ユーザー入力をそこに埋め込む前にエスケープすれば、<script> を書かれても <script> になって実行されない。XSSの基本対策だ。
ただし過信は禁物で、コンテキストが変われば必要なエスケープも変わる。href に入れるURL、<script> 内のJavaScript文字列、style 内のCSS——これらは5文字エスケープだけでは守れず、それぞれ専用のエンコードが要る。そして実務では、こうしたエスケープは自分で replace を並べるより、フレームワークの自動エスケープやmarkedとDOMPurifyの組み合わせのようなサニタイザに任せるのが安全。このツールの役割は、テンプレートに渡す前の文字列がどうエスケープされるかを目で確認したり、コピーしてきたHTMLのエンティティを元に戻して読む、という確認用途にある。
なお変換に使っている replace(/&/g, ...) のような正規表現の挙動を試したいときは正規表現テスターで確かめられる。
まとめ
- HTMLエスケープの5文字は
& < > " '。エスケープは&を必ず最初に置換する(他の4エンティティはすべて&で始まるので、順序を崩すと自分が作った<の&を二度打ちして二重エスケープ&lt;になる)。アンエスケープは逆に&を最後に処理する(先に戻すと二重デコードされて情報が失われる) - シングルクォートは名前付きの
'(HTML4で未定義)ではなく、どの世代のパーサでも通る数値文字参照'で出す - アンエスケープは寛容に。
'は'''の3表記すべてを受ける。出力は正規化・入力は寛容、が変換ツールの定石 - 5文字エスケープが守るのはHTMLテキスト/属性コンテキストまで。URL・JS・CSSは別。実装では自前置換よりサニタイザやフレームワークの自動エスケープに任せる
エスケープ後の見え方の確認や、エンティティの復元にどうぞ。入力はブラウザの外に出ない。
ぱんだツールズ では他にも Markdown→HTML変換・正規表現テスター・URLエンコード・JWTデコーダなど、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。