日々のコンピュータ情報の集積と整理

Dr.ウーパのコンピュータ備忘録

2015年2月21日土曜日

全体的なフィードバック - 3rd(1) - Bloggerの記事に目次をJavaScriptで自動的に付与する

3rd シリーズによって実現される目次
3rd シリーズによって実現される目次

イントロダクション

この記事は「そうだ!Bloggerの記事に目次を付けよう!」から始まる一連の「Bloggerの記事に目次をJavaScriptで自動的に付与する」シリーズの構成記事です。
(3rd シリーズ)

記事一覧は「Bloggerの記事に目次をJavaScriptで自動的に付与する - サポートページ」よりどうぞ。


3nd シリーズでは、2nd シリーズで作成した目次機能に対して、実際に使ってみて改善した方がよい点を改善していきます。



今回の目的

今回は、「Bloggerの記事に目次をJavaScriptで自動的に付与する」の 3rd シリーズの初回として、まずは 2nd シリーズの成果物に対して、今まで使ってきた中でのフィードバックを適用します。

最適化

処理の無駄を削減

いままでは、目次を本文に挿入する場合には、以下のコードにて行っていました。

            /* 本文に目次を挿入 */
            obj.innerHTML = html_index + obj.innerHTML;


このコードを使用した場合、元々の本文の html の先頭に目次の html を追加したものを、再度本文に設定していたために、ページ読み込み時にせっかく構築された本文の HTML がリセットされていました。

そこで、本文全体が再構築されるのを防止するために、以下のように目次全体の要素は createElement で作成し、本文に insertBefore で挿入するようにしました。

                // 目次の HTML を事前に要素に変換
                var index_obj = document.createElement("div");
                index_obj.setAttribute("id", "auto-generated-index");
                index_obj.innerHTML = "~省略~";


                /* 本文に目次を挿入 */
                obj.insertBefore(index_obj, obj.firstChild);


createElement の参考文献:

document.createElement - Web API インターフェイス | MDN
https://developer.mozilla.org/ja/docs/Web/API/document.createElement


setAttribute の参考文献:

element.setAttribute - Web API インターフェイス | MDN
https://developer.mozilla.org/ja/docs/Web/API/element.setAttribute


insertBefore の参考文献:

Node.insertBefore - Web API インターフェイス | MDN
https://developer.mozilla.org/ja/docs/Web/API/Node.insertBefore


これで、ページ読み込み後に構築されるのは、目次の html のみになるため、ページ表示時の負荷が軽減するはずです。


なお、目次の中身の生成は、文字列としての html のままにしました。
目次の中身の生成も今回修正したように、innerHTML で HTML ソースを挿入する方式ではなく、createElement 等を使用した DOM API に書き換えることも検討しましたが、今回は修正しないことにしました。

これは、innerHTML で要素を追加したケースと、DOM API を使用したケースで、なるべく高速に動作する方を選びたいところですが、要素の複雑性や処理方法、ブラウザの実装方法によってどうなるか評価しなければならないためです。

innerHTMLとDOM(appendChild,createElement等)、どっちが速い?[表示速度の比較] 勝手にブログカスタマイズ
http://blog12345.seesaa.net/article/372596473.html

innerHTMLとDOM(createElement..)での描画速度の比較 - Enjoy*Study
http://onozaty.hatenablog.com/entry/20051230/p1

DOM操作の最適化によるJavaScriptチューニング(前編) | HTML5Experts.jp
http://html5experts.jp/yoshikawa_t/1888/


DOM 要素の innerHTML に、何度も HTML ソースコードを継ぎ足していくような処理速度を低下させるような書き方はしていないので、大幅に処理速度を遅くするような書き方にはなっていないはずです。


セキュリティへの配慮

また、innerHTML ではなく、DOM API を使用する利点として、DOM based XSSのようなセキュリティの脅威の発生を防止できるような書き方が出来るという点です。

DOM APIとinnerHTMLは等価ではない - 泥のように
http://blog.tojiru.net/article/207321786.html


この目次を作成する JavaScript において、本文中に任意の html 要素が入り込むケースを上げてみます。

  • 見出し要素の id として、「">」などから始まる何らかの HTML の要素が混入した場合には、目次のリンクをクリックした場合に JavaScript が実行されたり、任意のページへ移動させられたり、任意の html 要素が作成される可能性があります。
  • 見出し要素へのアンカーとして、既存の見出し要素の id をそのまま利用しているため、目次の見出し要素へのリンクをクリックすると、既存の見出し要素の id が#つきで URL の末尾に記載されます。そのため、そのような URL を指定された場合に、そのページ内の他の JavaScript コードにそのようなURLに対する脆弱性があった場合には、その脆弱性を引き起こしてしまう可能性があります。
  • 見出し内のタグをそのまま使用する設定(use_headline_htmlTag = true)になっている場合には、見出し要素のタグ内に、何らかの html 要素が入っている場合には、任意の html 要素が作成される可能性があります。


以上のことから、ブログ本文の見出し要素の id、見出し本文に細工が施された場合には、セキュリティの脅威が発生することが分かります。


目次を作成するに当たり使用するデータは、この JavaScript コードが張られたページ内にある、ブログの本文中の見出し要素のテキストです。

そのため、基本的にはブログ作成者が作成したデータであり、セキュリティの脅威を生じるような悪意のある html が外部から混入する可能性は極めて低いと判断しました。

従って、ブログ本文の見出し要素の id、見出し本文に細工が施されているとするならば、既にそのページ自体にセキュリティ上の問題があるため、このスクリプト内で対処してもあまり効果はないと判断しました。

特に、見出し本文の html は、既にページに追加されている html なので、この部分に悪意のあるコードが含まれているとしたら、目次作成時の問題以前の問題でしょう。


とはいえ、ブログは html や JavaScript、セキュリティなどに詳しい人も詳しくない人も使用するものなので、デフォルトではブログ本文の見出し要素が細工されていた場合でも
安全に目次を作成できるようにしました。

        /* 
          見出しに細工がされていたとしても、セキュリティ上の脅威を防止する 
          このフラグを true にすると、use_headline_htmlTag の値にかかわらず、見出し内のタグは無効になります
        */
        var index_secure_run = true;


index_secure_run が true になっている場合、ブログ本文の見出し要素に細工が行われていたとしても、そのような細工を含まない安全な目次を作成します。

行う安全対策は次のとおりです。

  • index_secure_run が true になっている場合、見出し内のタグは無効にする
  • index_secure_run が true になっている場合、目次から各見出しへジャンプするときのアンカーとして、デフォルトで見出しに設定されている id は使用せず、新たに a タグを見出し内に挿入し、その id をアンカーとする

これで、上記で挙げた脅威を防ぐことができるはずです。
しかし、以上の対策で今現在考えられうる脅威への対策はできており問題は発生しないはずですが、innerHTML に直接タグを含めた値を設定している以上、未知の予期せぬ攻撃をされる危険性があります。

そのため、今後のシリーズでは、innerHTML による処理をすべて廃止し、DOM API による処理への移行を検討していきたいと思います。


バグフィックス

h から始まる他のタグがあった場合に、見出しであると誤認識しないようにする

前回のソースコードでは、見出しの要素のレベルを取得する処理で、h から始まるタグがあれば、その次の文字を取得して、見出しの要素としていました。

                /* 見出しタグの場合、見出しのレベルに応じて書式を設定して記録する */
                var originalTagName = obj.childNodes[i].tagName;
                if ( originalTagName !== void 0 ) {                 /* void 0 = undefined なので、originalTagName が undefined でなければ処理する */
                    var tagName = originalTagName.toLowerCase();
                    if ( tagName.lastIndexOf("h", 0) == 0 ) {
~省略~
                        var level = Number(tagName.substr(1, tagName.length - 1));      /* タグ hx の x の取り出し */
                        html_item += "<div id=\"auto-generated-index_content_h" + level + "\"><a href=\"#" + obj.childNodes[i].id + "\">" + headline + "</a></div>\r\n";


h1~h6 の見出し要素が入っていれば特に問題は無いのですが、万が一 h から始まる他のタグがあった場合、それを見出し要素だと誤認する可能性があったため、h の次の文字が数値かどうかをチェックして、数値の場合のみ目次要素として認識するようにしました。


コメント修正

ソースコードのコメントに、一部誤記があったため、修正しました。

修正前:
/* 見出し内のタグを削除して目次として使用するかどうか */
修正後:
/* 見出し内のタグを目次として使用するかどうか */


利便性上の修正

デフォルトのタイトル名変更

前回のソースコードでは、デフォルトのタイトルは「- INDEX -」でしたが、「目次」へ変更しました。

修正前:
/* 目次のタイトル */
var index_title = "- INDEX -";
修正後:
/* 目次のタイトル */
var index_title = "目次";


ページ内の JavaScript の空間の汚染防止

ページ内の JavaScript の空間を汚染しないように、以下のコードで、ソースコード全体を囲みました。

(function () {
ここに、目次作成用コード
})();


機能追加

ヘルプの表示・非常時の制御

目次の右上に表示されていたヘルプへのリンクの、表示・非表示を切り替えられる変数を追加しました。

/* 目次のヘルプを表示するかどうか */
var view_help = true;

view_help が true の場合には、ヘルプを表示し、view_help が false の場合には、ヘルプを表示しません。


修正後のソースコード

以上の修正を行ったソースコードを、以下に示します。
(なお、今回のシリーズのすべての修正・改良を行った配布用のコードは、3rd シリーズの最後のページにて、公開します。)

(function () {

    /* 生成処理制御パラメータ */
    var min_auto_generated_index_items = 2;         /* 見出しの数がこの数以上になった場合に見出しを挿入する */

    /* 目次挿入先要素の id の正規表現 */
    var regExp_id_obj_insert_index = new RegExp("^post-body-");

    /* 見出し内のタグを目次として使用するかどうか */
    var use_headline_htmlTag = false;

    /* 目次のタイトル */
    var index_title = "目次";

    /* 目次のヘルプを表示するかどうか */
    var view_help = true;

    /* 
    見出しに細工がされていたとしても、セキュリティ上の脅威を防止する 
    このフラグを true にすると、use_headline_htmlTag の値にかかわらず、見出し内のタグは無効になります
    */
    var index_secure_run = true;
    if (index_secure_run) {
        use_headline_htmlTag = false;
    }


    /* ページ読み込み時に目次の生成・挿入処理を実行 */
    generateIndex();


    /* --- lib --- */
    /* 
    HTML を エスケープする
    html : エスケープしたい html
         
    エスケープ後の文字列を返す

    注意:
    {本文に出力する html ソースをエスケープする場合}にのみ使用します
    タグの属性値 や URL などには使用してはなりません。
    */
    function escapeHTML(html) {
        /*
        要素のテキストデータとして文字列を設定して、
        その要素の HTML を取得すると、HTML 文書として表示する場合に、
        エスケープしなければならない文字がエスケープされることを利用
        */
        var obj = document.createElement('div');
        obj.appendChild(document.createTextNode(html));
        return obj.innerHTML;
    }

    /*
    要素に設定されている html から、htmlタグを削除した html を取得
    obj : element オブジェクト
    htmlタグを削除した html を返す


    注意:
    element.textContent が使用可能なブラウザでは、script タグや style タグの中身を取得しますが、
    element.textContent が使用できず、element.innerText が使用可能なブラウザでは、script タグや style タグの中身は取得できません

    */
    function getHTMLWithoutHTMLTagFromElement(obj) {

        /*
        html のタグ要素を削除したテキストとして要素の値を取得した後、
        エスケープ処理する
        */
        var headline_text = obj.textContent || obj.innerText;
        return escapeHTML(headline_text);
    }

    /* --- Main --- */

    /* 
    目次の生成・挿入処理 
    */
    function generateIndex() {
        /* 投稿本文が格納されている div 要素を発見し、目次を挿入する */
        var divs = document.getElementsByTagName("div");
        for (var i = 0; i < divs.length; i++) {
            if (regExp_id_obj_insert_index.test(divs[i].id)) {
                /* 投稿本文が格納されている div 要素発見時の処理 */
                generateIndexForObj(divs[i]);
                break;
            }
        }
    }

    /* 
    目次の生成・挿入処理
    obj : 目次の挿入先
    */
    function generateIndexForObj(obj) {

        /* 目次として使用する見出しの一覧のHTML */
        var html_item = "";

        /* 見出しの数 */
        var item_count = 0;

        /*
        再帰的に見出しを検索し、目次の HTML を作成する
        */
        generateIndexHTMLRecursive(obj);
        function generateIndexHTMLRecursive(obj) {

            /* 見出しの列挙 */
            for (var i = 0; i < obj.childNodes.length; i++) {

                /* 見出しタグの場合、見出しのレベルに応じて書式を設定して記録する */
                var originalTagName = obj.childNodes[i].tagName;
                if (originalTagName !== void 0) {                 /* void 0 = undefined なので、originalTagName が undefined でなければ処理する */

                    var tagName = originalTagName.toLowerCase();
                    if (tagName.lastIndexOf("h", 0) == 0) {

                        var level = Number(tagName.substr(1, tagName.length - 1));      /* タグ hx の x の取り出し */
                        if (!isNaN(level)) {

                            /* アンカー用の id */
                            var anchor_id = "auto-generated-index_target" + item_count;

                            if (index_secure_run) {

                                /* 既存の id が細工されている場合の脆弱性を防止するため、既存の id は用いず、新しくジャンプ用の要素を追加する */
                                var secure_anchor = document.createElement("a");
                                secure_anchor.setAttribute("id", anchor_id);
                                obj.childNodes[i].insertBefore(secure_anchor, obj.childNodes[i].firstChild);

                            } else {

                                /* アンカー設定
                                すでに id が設定されている場合は、その id をアンカーに用いる */
                                if (obj.childNodes[i].id != "") {
                                    anchor_id = obj.childNodes[i].id;
                                } else {
                                    obj.childNodes[i].id = anchor_id;
                                }
                            }

                            /* 見出しの内容 */
                            var headline = "";
                            if (use_headline_htmlTag) {
                                headline = obj.childNodes[i].innerHTML;
                            } else {
                                /* 見出し内の HTMLタグを使用しない場合には、HTML タグを削除する */
                                headline = getHTMLWithoutHTMLTagFromElement(obj.childNodes[i]);
                            }

                            html_item += "<div id=\"auto-generated-index_content_h" + level + "\"><a href=\"#" + anchor_id + "\">" + headline + "</a></div>\r\n";

                            item_count++;
                        }
                    }
                }

                /* 子ノードに対して再帰的に再帰的に見出しを検索 */
                generateIndexHTMLRecursive(obj.childNodes[i]);
            }
        }


        /* 目次のHTMLを本文へ挿入 */
        if (item_count >= min_auto_generated_index_items) {

            var help_html = "<span id=\"auto-generated-index_help\"><a href=\"http://upa-pc.blogspot.jp/p/addindex.html\" title=\"この目次について\" rel=\"nofollow\">?</a></span>";


            /* 目次の HTML を事前に要素に変換 */
            var index_obj = document.createElement("div");
            index_obj.setAttribute("id", "auto-generated-index");
            index_obj.innerHTML = "<div id=\"auto-generated-index_title\">" + index_title +
                    (view_help ? help_html : "") +
                     " </div>" +
                     "<div id=\"auto-generated-index_content\">" + html_item + "</div>" +
                     (index_secure_run ? "<!-- セキュアな目次 -->" : "");


            /* 本文に目次を挿入 */
            obj.insertBefore(index_obj, obj.firstChild);
        }
    }

})();


修正についての特記事項

見出しからの HTML の除去について

index_secure_run (セキュリティ上の脅威を防止する)が true になっている場合や、use_headline_htmlTag (見出し内のタグを目次として使用するかどうか)が false になっている場合には、見出しから HTML タグを除去しますが、前回のソースコードでは、次のような正規表現による文字列置換によって、HTML タグの除去を行っていました。

                            /* 見出し内の HTMLタグを使用しない場合には、HTML タグを削除する */
                            headline = headline.replace(/<[^>]*>/g, "");

しかし、この正規表現による HTML タグの除去は簡易的なものであり、タグ内の属性の値として ">" が入っていた場合には、正常にタグを除去できませんでした。(HTML のタグとして動作はしませんが、除去しきれなかったタグの中身の一部がブラウザで見えてしまうという現象が発生します。)

そこで、次のような HTML タグの要素を表す Element オブジェクトの textContent や innerText を取得することで HTML タグを除去することにしました。

    /*
    要素に設定されている html から、htmlタグを削除した html を取得
    obj : element オブジェクト
    htmlタグを削除した html を返す


    注意:
    element.textContent が使用可能なブラウザでは、script タグや style タグの中身を取得しますが、
    element.textContent が使用できず、element.innerText が使用可能なブラウザでは、script タグや style タグの中身は取得できません

    */
    function getHTMLWithoutHTMLTagFromElement(obj) {

        /*
        html のタグ要素を削除したテキストとして要素の値を取得した後、
        エスケープ処理する
        */
        var headline_text = obj.textContent || obj.innerText;
        return escapeHTML(headline_text);
    }


HTML タグの要素を表す Element オブジェクトの textContent や innerText では、html の文書をブラウザで表示した時に、目で見える形のテキストを取得することができます。

Node.textContent - Web API インターフェイス | MDN
https://developer.mozilla.org/ja/docs/Web/API/Node.textContent


その性質を利用して、HTML タグの含まれるデータから、HTML タグを除去したテキストを取得しています。

なお、ソースコード中のコメントにもあるように、textContent では、script タグや style タグの中身が取得できてしまうため、注意が必要です。


HTML のエスケープ処理について

上記の HTML タグの除去を行った後のテキストに対して、エスケープ処理を施しています。

エスケープ処理を施す理由は、タグを除去するために textContent や innerText を取得した結果、HTML ではエスケープされていた &lt;(<) や &gt;(>) がアンエスケープされてしまい、HTML タグとして機能する < や > になっているため、目次作成の過程でそのデータを innerHTML に代入する必要があるため、HTML のタグとして機能することがないように、再びエスケープしています。

HTML のエスケープ処理は、以下のように DOM の element オブジェクトにテキストを追加した時に、その innerHTML を取得すると、追加したテキストをエスケープしたものとして取得できる性質を利用しています。

    HTML を エスケープする
    html : エスケープしたい html
         
    エスケープ後の文字列を返す

    注意:
    {本文に出力する html ソースをエスケープする場合}にのみ使用します
    タグの属性値 や URL などには使用してはなりません。
    */
    function escapeHTML(html) {
        /*
        要素のテキストデータとして文字列を設定して、
        その要素の HTML を取得すると、HTML 文書として表示する場合に、
        エスケープしなければならない文字がエスケープされることを利用
        */
        var obj = document.createElement('div');
        obj.appendChild(document.createTextNode(html));
        return obj.innerHTML;
    }

なお、このエスケープ処理は HTML 文書の本文に出力するデータの場合のみ、このような処理でエスケープが実現できます。

ソースコード中のコメントにもあるように、HTML タグの属性に出力する値や URL などのエスケープには利用できません。(エスケープしなければならない文字が異なるため。)


なお、このエスケープ処理は以下の情報を参考にしました。



まとめ

以上で、前回の2nd シリーズのソースコードをいままで使ってきた中で見えてきた点の、JavaScript コードへの反映は終了です。

次回は、スタイルシートに対して、いままで使ってきた中で見えてきた点を反映させます。



「Bloggerの記事に目次をJavaScriptで自動的に付与する」シリーズ 記事一覧へ




関連記事

関連記事を読み込み中...

同じラベルの記事を読み込み中...