JavaScript:unescapeHTMLの妥当な実装

Posted by Hiraku on 2011-06-23

JavaScriptにはHTMLを実体参照化する関数、PHPで言うところのhtmlspecialchars()にあたる関数が存在しません。

正式な理由はよく知りませんが、教科書的な回答としては、「DOMを使えばエスケープなんて気にしなくていいよ」が挙げられるでしょう。うだうだ言わず黙ってDOMを使うべし。

…まあでも、必要なケースもあるでしょう。特にinnerHTMLの高速性は魅力的です。T.jsを作ったときにベンチマークを取ったのですが、エスケープ関数をはさんでもinnerHTMLの方が高速に動作することが確かにありました。

そんなわけで、世の中にはHTMLをエスケープする自前実装の関数があふれています。さんざん語り尽くされている気もしますが、prototype.jsの実装を見ていて少し気になったので取り上げてみます。

escapeHTMLの場合

escapeHTML
文字列中の特定の文字を実体参照に置き換え、HTMLとして認識されないようにする関数。特にXSSを防ぐために使用することを想定する。

要求仕様はこんな感じだと思います。

素直に作ると、誰でもreplaceでひとつひとつ置換していく実装をするでしょう。そしてそれが一番高速で妥当になります。

function escapeHTML(str) {
  return str.replace(/&/g, '&')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
}
//--------------------------
escapeHTML('<>&"\''); // &lt;&gt;&amp;&quot;&#039; 

強いて言うなら、正規表現をクロージャーかなにかでキャッシュすると少し速くなるかもしれないですね。メジャーどころもだいたいこんな実装をしています。

で、問題はこいつを元に戻す方の関数、unescapeHTMLです。

unescapeHTMLの場合

unescapeHTML
文字列中の実体参照を生の文字に戻す関数。

unescapeHTMLには、たぶん誰もがこういう仕様を期待すると思うのです。しかし、prototype.jsの実装をみていると、なんだか妙にコードが短いのです。(読みやすいように改行をいれました)

  function unescapeHTML() {
    return this.stripTags()
               .replace(/&lt;/g,'<')
               .replace(/&gt;/g,'>')
               .replace(/&amp;/g,'&');
  }

これだと、&#039;みたいな数値参照は軒並み戻りません。うーん、これだけしか元に戻してないのは、パフォーマンスの問題とかですかね?

そこで、パフォーマンスとかとりあえず無視して、ちゃんと数値参照も含めて文字に戻してくれるunescapeHTMLを考えてみました。

こういうのはDOMとinnerHTMLを使うと簡単ですよね。

function unescapeHTML(str) {
  var div = document.createElement("div");
  div.innerHTML = str.replace(/</g,"&lt;")
                     .replace(/>/g,"&gt;")
                     .replace(/ /g, "&nbsp;")
                     .replace(/\r/g, "&#13;")
                     .replace(/\n/g, "&#10;");
  return div.textContent || div.innerText;
}

たぶんこれでOK。innerHTML側に突っ込んで、textContent側で取り出すと生の文字に戻っているという寸法です。

いろいろreplaceしてるのは、escapeしてない文字列がまぎれてると失敗するのと、連続するスペースが消えるのを防ぐためです。(最近のIEは大丈夫なんでしたっけ?) innerTextとtextContentを列挙しているのはFirefox対策です。

innerTextが非標準で、textContentの方が標準ということなので、textContent側を優先させるように修正しました。

あまりしっかりテストできてないので、バグがあったら教えてください。

unescapeHTMLの使いどころは…サーバーサイドのプログラムがおせっかいにも全文字列をエスケープして渡してくる時とか…?


補足

ちなみに、DOMとinnerHTMLを使って逆にescapeHTMLを実装しているケースをたまに見かけますが、こちらは「"」や「'」をエスケープしてくれず、XSS対策にならないので使うのは危険です。素直にreplaceするのが一番です。


補足2:その他のescapeHTMLの実装方法

他にも色々あります。が、まあ個人の趣味でいい範囲かと思います。

function escapeHTML(str) {
  return str.replace(/[&<>"']/g,
    function($0){
      return escapeHTML.dic[$0]
    }
  );
}
escapeHTML.dic = {
  "&":"&amp;",
  "<":"&lt;",
  ">":"&gt;",
  '"':""",
  "'":"'"
};

replaceは関数を渡せるので、もう少しまとめることができます。

文字列の走査は一回になりますが、バックトラッキングや自作関数の呼び出しコストなんかもかかるので、効率が上がるかというとベンチマークとらないと何とも言えない気がします。。replaceの順番に気を使わなくていい点はメリットですかね。

昔のMac Safariだとこのcallbackスタイルのreplaceは対応していなかったそうで、replaceを重ねている実装の方が多いのは、そのためかも知れません。

function escapeHTML(str) {
  var i,len,r="",dic=escapeHTML.dic;
  for (i=0,len=str.length; i<len; ++i) {
    if (str[i] in dic) {
      r += dic[str[i]];
    } else {
      r += str[i];
    }
  }
  return r;
}
escapeHTML.dic = {
  "&":"&amp;",
  "<":"&lt;",
  ">":"&gt;",
  '"':"&quot;",
  "'":"&#39;"
};

dictionaryを作るならもう正規表現なんていらないお!!

+=を使っているので古いIEで遅くなりそう。。

keyword: javascript

JavaScriptの最新記事