できない.dev

HTMLの特殊文字をエスケープするには

HTML に埋め込む文字列の & < > と引用符を文字参照に置き換えるエスケープを各言語で示す。
置換の順序と、引用符の変換結果が言語ごとに違う点までを扱う。

公開:

選んだ言語は、ほかのサンプルコードを開いたときも最初に表示する。

Python

import html
 
raw = '<a href="x?a=1&b=2">Tom & \'Jerry\'</a>'
 
escaped = html.escape(raw)
print(escaped)
# &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#x27;Jerry&#x27;&lt;/a&gt;
 
print(html.escape(raw, quote=False))
# &lt;a href="x?a=1&amp;b=2"&gt;Tom &amp; 'Jerry'&lt;/a&gt;(引用符は変換されない)
 
print(html.unescape(escaped) == raw)  # True

標準の html.escape が & < > と二重引用符・単一引用符を文字参照に変換する。
テキストだけに埋め込み、引用符を変換したくないときは quote=False を渡す。
単一引用符は &#x27; になり、他の言語の &#39; とは書き方が違うが、どちらも同じ文字を指す数値参照である。

JavaScript

const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
 
function escapeHtml(text) {
  return text.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
}
 
const raw = `<a href="x?a=1&b=2">Tom & 'Jerry'</a>`;
console.log(escapeHtml(raw));
// &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;

標準の HTML エスケープ関数が無いため、対象の 5 文字を対応表で置き換える関数を用意する。
正規表現で 1 回の走査にまとめておけば、& を先に置き換えた結果を後続の置換が壊す順序の問題が起きない。

TypeScript

const ESCAPES: Record<string, string> = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#39;",
};
 
function escapeHtml(text: string): string {
  return text.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
}
 
const raw: string = `<a href="x?a=1&b=2">Tom & 'Jerry'</a>`;
console.log(escapeHtml(raw));
// &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;

対応表を Record<string, string> で型付けし、引数と戻り値を string にしておく。
正規表現が 5 文字だけに絞っているので、ESCAPES[ch] が undefined になることはない。
実行時の振る舞いは JavaScript と同じである。

Go

package main
 
import (
	"fmt"
	"html"
)
 
func main() {
	raw := `<a href="x?a=1&b=2">Tom & 'Jerry'</a>`
 
	escaped := html.EscapeString(raw)
	fmt.Println(escaped)
	// &lt;a href=&#34;x?a=1&amp;b=2&#34;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;
 
	fmt.Println(html.UnescapeString(escaped) == raw) // true
}

標準の html パッケージの EscapeString が変換、UnescapeString が元に戻す関数である。
二重引用符は &quot; ではなく &#34; に、単一引用符は &#39; に変換される。
html/template のテンプレートは出力する場所に応じて自動でエスケープするため、この関数は手で組み立てた HTML に値を埋め込む場面で使う。

Rust

fn escape_html(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(ch),
        }
    }
    out
}
 
fn main() {
    let raw = r#"<a href="x?a=1&b=2">Tom & 'Jerry'</a>"#;
    println!("{}", escape_html(raw));
    // &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;
}

標準ライブラリに HTML エスケープの関数は無いため、文字ごとに match で置き換える関数を書く。
1 文字ずつ新しい String へ書き出すので、& の置換順を気にしなくてよい。
依存クレートを増やさずに済むのも利点である。

Java

public class Main {
    static String escapeHtml(String text) {
        StringBuilder out = new StringBuilder(text.length());
        for (char ch : text.toCharArray()) {
            switch (ch) {
                case '&' -> out.append("&amp;");
                case '<' -> out.append("&lt;");
                case '>' -> out.append("&gt;");
                case '"' -> out.append("&quot;");
                case '\'' -> out.append("&#39;");
                default -> out.append(ch);
            }
        }
        return out.toString();
    }
 
    public static void main(String[] args) {
        String raw = "<a href=\"x?a=1&b=2\">Tom & 'Jerry'</a>";
        System.out.println(escapeHtml(raw));
        // &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;
    }
}

標準ライブラリには HTML をエスケープする汎用のメソッドが無いため、char ごとに switch で置き換える関数を書く。
StringBuilder に書き出す方式なので、& を先に処理する順序を意識しなくてよい。

C#

using System;
using System.Net;
 
var raw = "<a href=\"x?a=1&b=2\">Tom & 'Jerry'</a>";
 
var escaped = WebUtility.HtmlEncode(raw);
Console.WriteLine(escaped);
// &lt;a href=&quot;x?a=1&amp;b=2&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/a&gt;
 
Console.WriteLine(WebUtility.HtmlDecode(escaped) == raw); // True

System.Net.WebUtility.HtmlEncode が標準で用意されており、HtmlDecode で元に戻せる。
引用符は &quot; と &#39; に変換される。
é や © のような ASCII の範囲外の一部の文字も &#233; や &#169; の数値参照に変換されるが、日本語はそのまま残る。

つまずき

変換する文字は & < > " ' の 5 つで、特に & の扱いで失敗しやすい。
replace を連ねて < を &lt; にしてから & を &amp; にすると、a<b が a&amp;lt;b になり、画面に「&lt;」という文字がそのまま出てしまう。
& を最初に置き換えるか、今回のように 1 回の走査で全部を置き換える。

同じ文字列を二重にエスケープしても同じ問題が起きる。
Python で html.escape(html.escape("Tom & Jerry")) を実行すると Tom &amp;amp; Jerry になった。
エスケープは HTML に出力する直前に 1 回だけ行う。

言語で違う変換結果

同じ入力でも、引用符と非 ASCII 文字の変換結果は言語ごとに違った。

  • 単一引用符: Python は &#x27;、Go・C# と自作関数(JavaScript・TypeScript・Rust・Java)は &#39;
  • 二重引用符: Go は &#34;、それ以外は &quot;
  • é と ©: C# は &#233; と &#169; に変換し、Python と Go はそのまま残した

どれもブラウザでは同じ文字として表示されるが、出力を文字列として比較するテストでは、使う関数を固定しておく。

この変換が効く範囲

5 文字の変換は、HTML のテキストと、引用符で囲んだ属性値の中に入れる文字列に対するものである。
<script> の中身や、href に入れる URL は解釈のされ方が別なので、この変換だけでは安全にならない。
URL の一部を載せるときは、URL エンコードを別に行う。

このコードで動いた?