JavaScriptセキュリティの基礎知識

第8回DOM-based XSS その3

# 第8回 DOM-based XSS その3

前回および前々回で、DOM-based XSSに関する基本的な内容および対策方法の原則について説明しました。今回も引き続きDOM-baed XSSに関する話題を続けますが、これまでに説明した内容より応用的な話題を取り上げます。

一部のタグを許容してHTMLを組み立てる3つの場面

JavaScriptを使用して複雑なHTMLの操作を行うようなWebアプリケーションにおいては、⁠一部のタグを許容してHTMLを生成する処理をJavaScript上で行う」という場合もあるかもしれません。タグを許容しつつHTMLを生成するという場面は、たいていは以下のいずれかのような状況でしょう。

  • サーバ側でHTML断片となる文字列を生成し、ブラウザ上でHTML内に流し込む
  • あらかじめ定まった構造のHTMLをJavaScriptにて生成し、その一部にデータを当てはめる
  • ユーザーからの入力に基づき、自由にHTMLを生成する

それぞれの状況について、順に説明していきます。

サーバ側でHTML断片となる文字列を生成し、ブラウザ上でHTML内に流し込む

「XMLHttpRequestを用いて、必要となったタイミングで随時サーバ上からデータを取得し、ブラウザ上ではJavaScriptによってHTMLを更新する」といういわゆるAjaxアプリケーションにおいて、描画速度の向上などのためにサーバ上であらかじめHTMLの断片を生成し、ブラウザ上ではそれを表示対象となるHTML要素のinnerHTMLに代入する場合があります。典型的には、以下のようなコードになります。

var xhr = new XMLHttpRequest();
var url = "/news-update";
xhr.open("GET", url, true);
xhr.onload = function () {
    // サーバ上では「<div><a href="/news/20150101/">新製品のお知らせ</a></div>」のような文字列を返す
    document.getElementById("content").innerHTML = xhr.responseText;
};
xhr.send(null);

このような場合、以下のいずれかの条件でDOM-based XSSが発生することになるので、注意が必要です。

  • サーバ上で生成されるHTML断片文字列にXSSが存在する
  • XMLHttpRequestの接続先が攻撃者によってコントロール可能

ブラウザ上のJavaScriptでは、サーバ側で生成されたHTML断片文字列を無条件にinnerHTMLへと代入しているので、このHTML断片文字列内に「<」⁠>」などが攻撃者によって挿入可能な場合にはXSSが発生することになります。そのため、サーバ上でHTMLページ全体ではなくHTMLの断片を生成する場合においても、従来同様のXSS対策が必要となります。すなわち、連載第2回Webセキュリティのおさらい その2 XSSで説明した、従来どおりの基本的なXSS対策をサーバ上でのHTML断片の生成処理において徹底して適用する必要があります。

また、現在のブラウザでは、XMLHttpRequestは異なるオリジンへの接続も可能なため、XMLHttpRequestの接続先が攻撃者によってコントロール可能な場合には攻撃者の用意した任意のコンテンツがinnerHTMLに代入されることとなり、XSSへとつながります。

たとえば次のコードは、http://example.jp/#/newsのようなURLへアクセスした場合に、http://example.jp/newsの内容をXMLHttpRequestで取得することを想定して書かれたものですが、攻撃者がユーザーをhttp://example.jp/#//attacker.example.com/のようなURLへ誘導して、attacker.example.com上に用意したコンテンツをXMLHttpRequestで取得させ、それをinnerHTMLへ代入することになり、XSSが発生します。

// bad code
// URL内の#より後ろの部分をXMLHttpRequestの接続先として使用
// http://example.jp/#/newsであれば/newsをXMLHttpRequestで取得する
// 攻撃者はhttp://example.jp/#//attacker.example.com/のようなURLへ誘導することでXSSが発生する

var url = location.hash.substring(1);
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true); // XMLHttpRequestの接続先はhttp://attacker.example.com/となる
xhr.onload = function () {
    document.getElementById("content").innerHTML = xhr.responseText;
};
xhr.send(null);

この対策として、XMLHttpRequestの接続先を自身のサイトのみに限定させる必要がありますが、接続先のURLが自サイトのものかなどを逐一確認する方法では漏れが発生しやすくなります。また、サイト内にオープンリダイレクタが存在した場合には、外部サイトへ接続されてしまう可能性もあります。そのため、⁠XMLHttpRequestの接続先をあらかじめ固定のリストとして保持しておき、それ以外のURLとは接続しない」という方法を採るのがいいでしょう。この方法であれば、容易に安全性を確保できます。

// URL内の#より後ろの部分でXMLHttpRequestの接続先を識別
// http://example.jp/#newsであれば/newsを、http://example.jp/#updateであれば/updateをXMLHttpRequestで取得する
// 接続先候補としては/news、/update、/infoがあるとする
var pages = {
    news : "/news",
    update : "/update",
    info : "/info"
};
var target = location.hash.substring(1);
var url = pages[target];
if (url === undefined || !pages.hasOwnProperty(target) ) {
    return; // リストに存在しない場合は関数を抜ける
}
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.onload = function(){
    document.getElementById("content").innerHTML = xhr.responseText;
};
xhr.send(null);

XMLHttpRequestを使用する際のセキュリティ上の注意点については、今後の回にてより詳細に解説します。

あらかじめ定まった構造のHTMLをJavaScriptにて生成し、その一部にデータを当てはめる

たとえば「XMLHttpRequestなどを経由して外部からデータをJSONとして受け取り、それをもとに、特定書式のHTMLをJavaScript上で生成する」といった場合に、定められた形式に基づいたHTML断片をJavaScriptを使用して生成し、それをページの一部として埋め込むという処理が必要となることがあります。

具体例として、以下のコードを考えてみましょう。変数friendsには、外部に保存されているアドレス帳のデータが配列として保存されています。これを1つのエントリごとに変数templateで指定された書式に従って展開し、HTML上に表示するというコードです。多くの場合、こういった処理には何らかのJavaScript製のテンプレートエンジン(ライブラリ)やデータバインディング機構のあるフレームワークを使用することになると思いますが、ここではあくまでも説明のためにそういったライブラリやフレームワークを使用せずに、同種のコードを直接実装することにします。

// bad code
/* 
    変数friendsには以下のような配列が格納されている
    [
        {
            "name" : "山田太郎",
            "mail" : "yamada@example1.jp",
            "birthday" : "1980-05-19"
        },
        {
            "name" : "鈴木一郎",
            "mail" : "suzuki@example2.jp",
            "birthday" : "1991-10-22"
        },
        {
            "name" : "John Smith",
            "mail" : "john@example3.jp",
            "birthday" : "1993-3-27"
        }
    ]
*/

function expandTemplate (template, friends) {
    // テンプレート文字列中の「%name%」「%birthday%」「%mail%」をそれぞれ変数に置換する関数
    var i, s, html = "";
    for (i = 0; i < friends.length; i++) {
        s = template.replace(/%(\w+)%/g, function (s, param) {
            if (param === "name") return friends[ i ].name;
            else if (param === "birthday") return friends[ i ].birthday;
            else if (param === "mail") return friends[ i ].mail;
            else return "%" + param + "%";
        });
        html += s;
    }
    return html;
}

var template = '<div>%name% さん<br>メールアドレス:<a href="mailto:%mail%">%mail%</a> 誕生日:%birthday%</div>';
var elm = document.getElementById("contacts");
var html = expandTemplate(template, friends);
elm.innerHTML = html;

このようなコードでは、攻撃者がアドレス帳の中身をコントロール可能な場合にはXSSが発生することになります。たとえば、攻撃者が自身のコンタクト先情報として、以下のような名前とメールアドレスを設定していたとしましょう。

{
    "name" : "<img src=# onerror=alert(1)>",
    "mail" : "\"onmouseover=alert(2)//\"@example.jp",
    "birthday" : "2000-01-01"
}

攻撃者のこのようなコンタクト先が、ユーザーのブラウザ上でexpandTemplate関数によって処理された場合、生成されるHTMLは以下となり、名前欄とメールアドレス欄に攻撃者の仕込んだスクリプトalert(1)およびalert(2)がユーザのブラウザ上で動作してしまいます。

<div><img src=# onerror=alert(1)> さん<br>メールアドレス:<a href="mailto:"onmouseover=alert(2)//"@example.jp">"onmouseover=alert(2)//"@example.jp</a> 誕生日:2000-01-01</div>

このような処理でのDOM-basedの発生を防ぐ最もかんたんな方法は、テンプレートの文字列を変数と置換する際に、HTMLのメタキャラクタをエスケープすることです。

function htmlEscape (s) {
    s = s.replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#x27;");
    return s;
}

function expandTemplate (template, friends){
    var i, s, html = "";
    for (i = 0; i < friends.length; i++){
        s = template.replace( /%(\w+)%/g, function(s, param){
            if (param === "name") return htmlEscape(friends[ i ].name);
            else if (param === "birthday") return htmlEscape( friends[ i ].birthday);
            else if (param === "mail") return htmlEscape(friends[ i ].mail);
            else return "%" + param + "%";
        });
        html += s;
    }
    return html;
}

このように、従来サーバ上で行っていたエスケープと同様の処理をJavaScript上で行うことで、DOM-based XSSの発生を防ぐことができます。

先ほどの例と同様、攻撃者が自身のコンタクト先情報として以下のようなアドレスを設定していたとします。

{
    "name" : "<img src=# onerror=alert(1)>",
    "mail" : "\"onmouseover=alert(2)//\"@example.jp",
    "birthday" : "2000-01-01"
}

この場合であっても、テンプレート文字列に従ってコンタクト先情報を展開する際にJavaScriptでエスケープ処理を行っていれば、生成されるHTMLは以下のようになり、XSSは発生しません。

<div>&lt;img src=# onerror=alert(1)&gt; さん<br>メールアドレス:<a href="mailto:&quot;onmouseover=alert(2)//&quot;example.jp">&quot;onmouseover=alert(2)//&quot;example.jp</a> 誕生日:2000-01-01</div>

先にも述べたように、今回紹介したような定型的なHTMLを繰り返し生成するような処理では、一般的にはテンプレートエンジン(ライブラリ)やデータバインディング機構を持つフレームワークを用いることが多いと思います。ただし、そういったサードパーティ製のライブラリやフレームワークを導入する場合でも、それらによってHTMLを生成する際に、

  • 自動的にエスケープされた値が埋め込まれるのか
  • 明示的に指定した場合にのみエスケープされるのか

といった点は確認しておく必要があります。

また、使用するライブラリやフレームワークの新しいバージョンがリリースされた場合には、脆弱性の修正が含まれていないかを確認し、脆弱性の修正が含まれている場合にはライブラリを使用しているサイトにおいても新しいバージョンに更新するという作業を忘れてはいけません連載第6回参照⁠⁠。

ユーザーからの入力に基づき、自由にHTMLを生成する

テキスト装飾用のHTMLを埋め込むためのリッチテキストエディタや、MarkdownからHTMLへの展開をブラウザ上で行う場合など、アプリケーションの種類によっては、ユーザーからの入力に基づき自由にHTMLを生成する処理をJavaScriptによって実現することを求められる場合があります。

通常、こういった複雑な処理をまったくのゼロから開発することは稀で、一般的にはサードパーティ製のリッチテキストエディタライブラリやMarkdown展開のためのライブラリを使用することになるでしょう。たとえば、MarkdownからHTMLへの展開にmarked.jsを使用する場合、コードは以下のようになります。

<script src="lib/marked.js"></script>
...
<div id="content"></div>
<script>
    var markdownText = "# title\n\n-list 1\n-list 2\n";
    document.getElementById("content").innerHTML = marked(markdownText);
</script>

このとき、変数markdownTextに「<img src=# onerror=alert(1)>」のようなHTMLおよびJavaScript文字列が含まれていると、Markdownから生成されたHTMLにもその内容がそのまま展開されてしまい、変数markdownTextが攻撃者によってコントロール可能な場合にはXSSにつながります。

じつは、marked.jsではsanitizeオプションを指定することで、Markdownとして定められた構文のみを解釈し、任意のHTMLタグの生成を抑制することができます。

// 自由なHTMLの生成を抑制
var markdownText = "# title\n\n<img src=# onerror=alert(1)>\n<img src='/img/fig.png'>";
document.getElementById("content").innerHTML = marked(markdownText,{sanitize:true}); // <img>はどちらも生成されない

ただし、この場合にはユーザーは任意のHTMLタグを使うことができず、Markdownの限られた書式のみしか利用できないことになり、ユーザーにとってみると作成できるHTMLの自由度が下がることになります。また、marked.jsのsanitizeオプションを指定した場合に本当に安全上の問題点がないかを確認する必要もあります。marked.js以外のライブラリを使用したいと思った場合には、同種のオプションが存在しない可能性もあります。

そこで、生成されるHTMLの自由度は最大限に保ちつつ、XSSを発生させない方法を考えましょう。

このような場合に採れる方法として、⁠DOMParser APIやcreateHTMLDocumentを使用してHTML文字列をパースし、それにより構築されたDOMツリーから許可されたタグと属性のみを取り出す」というものが知られています第4回 危険性が理解されにくいネイティブアプリ内XSS(2⁠⁠:フロントエンドWeb戦略室⁠。

DOMParser APIやcreateHTMLDocument APIは、最近のブラウザに搭載されているAPIで、ブラウザが現在表示しているdocumentに影響を与えることなく、与えられた文字列をパースして、DOMツリーを構築することができます。これらのAPIを使用して、構築されたDOMツリーに対し、そのDOMツリー内のすべてのノードおよび属性を列挙し、それらのうち許可されたタグおよび属性以外をすべて除外するのです。

DOMParserを使ってDOMツリーを構築し、許可されたタグと属性のみを取り出すコードの例を以下に示します。

function safeHtml (htmlString) {
    var parser, doc, body, i, newNode, parentNode, buildNode;
    parser = new DOMParser();
    doc = parser.parseFromString(htmlString, "text/html");
    body = doc.body;
    parentNode = document.createElement("div");

    buildNode = function (node) {
        var i, elm, childNode, attrName, attrValue;

        switch (node.nodeType) {
        case 1: // ELEMENT_NODE
            if (node.tagName === "DIV" || node.tagName === "IMG") {
                elm = document.createElement(node.tagName); 
                if (node.tagName === "IMG") {
                    for (i=0; i<node.attributes.length; i++) {
                        attrName = node.attributes[i].name;
                        attrValue = node.attributes[i].value;
                        if (attrName === "src" || attrName === "title" || attrName === "alt") {
                            elm.setAttribute( attrName, attrValue );
                        }
                    }
                }
                for (i=0; i<node.childNodes.length; i++) {
                    console.log(node.childNodes[i]);

                    childNode = buildNode(node.childNodes[i]);
                    if (childNode !== undefined) {
                        elm.appendChild( childNode );
                    }
                }
            }
            break;
        case 3: // TEXT_NODE
            elm = document.createTextNode(node.textContent);
            break;
        }
        return elm;
    };

    for (i=0; i <body.childNodes.length; i++) {
        newNode = buildNode(body.childNodes[i]);
        if (newNode !== undefined) {
            parentNode.appendChild(newNode);
        }
    }
    return parentNode.innerHTML;
}

unsafeHtml = '<div>Hello, Sanitize!<img src=# alt="incorrect image" onerror=alert(1)></div>';
sanitizedHtml = safeHtml(unsafeHtml);
elm.innerHTML = sanitizedHtml; // <div>Hello, Sanitize!<img src="#" alt="incorrect image"></div>

この例では、DOMParserによって構築されたDOMツリーの子要素および属性を列挙し、divタグ、imgタグ、テキストノードだけを許可、さらにimg要素に関してはsrc属性、title属性、alt属性のみを許可し、それら以外を除去した安全なHTML文字列を返しています。

たとえば、以下の文字列を与えた場合には、div要素、img要素、テキストノードおよびimg要素内のsrc属性、alt属性は保持されますが、img要素内のonerror属性は削除されます。

<div>Hello, Sanitize!<img src=# alt="incorrect image" onerror=alert(1)></div>

結果として、以下のHTML文字列を得ることができます。

<div>Hello, Sanitize!<img src="#" alt="incorrect image"></div>

実際にこのような方法で安全なHTMLを組み立てるためのライブラリの実装としては、Mario Heiderich氏率いるCure53によるDOMPurifyなどがあります。⁠ユーザーからの入力に基づき自由にHTMLを組み立てつつも、生成されるHTMLの安全性は確保したい」という場合には、こういったライブラリの使用を検討するといいでしょう。

おすすめ記事

記事・ニュース一覧