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

第5回問題を発生させにくくするURLの扱い

前回は、セキュリティ上の境界条件はオリジンという単位で行われること、オリジンはURLをもとに決定されることを説明しました。今回は、実際にJavaScript内でURLというデータをどのように扱えばセキュリティ上の問題を発生させにくくなるのかについて解説していきます。

locationオブジェクト

locationオブジェクトの基本

JavaScriptにおいてURLを取り扱う最も基本的な要素の1つが、locationオブジェクトでしょう。locationオブジェクトには現在表示しているドキュメントのURLが格納されており、locationオブジェクトを操作することで現在のドキュメントから違うURLへページを遷移させることもできます。

locationオブジェクトの代表的なプロパティおよびメソッドを下表に示します。

locationオブジェクトの代表的なプロパティおよびメソッド
メソッド説明
assign引数で指定されたURLへ移動する
reloadページをリロードする。引数でtrueが指定された場合は、キャッシュを用いずに、サーバからリロードする
replace引数で指定されたURLへ移動するが、移動の履歴が残らない
プロパティ説明
protocolURLのプロトコル部分
hostURLのホスト部分。ポート番号があればそれも含む
hostnameURLのホスト部分。ポート番号は含まない
portURLのポート番号部分。デフォルトポートの場合は、空文字列
pathnameURLのパス名部分
searchURLの?以降のクエリ文字列
hashURLの#以降
href完全なURL全体

現在、主要なブラウザのほとんどでは、表示しているページの正規化されたオリジンをJavaScript上から取得するためのoriginプロパティがlocationオブジェクトに実装されています。

IE10以下などではlocation.originは実装されていないので、location.originプロパティを参照したい場合には、以下のようなコードを入れておくといいでしょう。

if( location.origin === undefined ){
    location.origin = location.protocol + "//" + location.hostname + (location.port ? ":" + location.port : "");
}

console.log( location.origin );  // 現在のオリジンを表示

たとえば、現在のドキュメントがhttp://example.jp:8080/list/?file=news.html#recentであった場合、locationオブジェクトの各プロパティの値は以下のようになります。

  • protocol → http:
  • host → example.jp:8080
  • hostname → example.jp
  • port → 8080
  • pathname → /list/
  • search → ?file=news.html
  • hash → #recent
  • href → http://example.jp:8080/list/?file=news.html#recent
  • origin → http://example.jp:8080

location.href以外に現在のドキュメントのURLを取得するためのインタフェース

現在のドキュメントの完全なURLを取得するためには、一般的にはlocation.hrefを使用しますが、ブラウザによってはlocation.href以外にも現在のドキュメントのURLを取得するためのインタフェースを持っている場合があります。以下にそれらの一部を示します。

  • document.URL
  • document.documentURI(IE10、11ではサポートされていない)
  • document.URLUnencoded(IE7~IE11のみサポート)

document.URLUnencodedはInternet Explorerのみでサポートされており、file:スキームなどの場合にdocument.URLとは異なる結果を返します(⁠⁠パス区切りが/ではなく\になる」⁠パス中のスペースが%20になる」などの差があります⁠⁠。

余談ですが、IE7およびIE8では、document.URLUnencodedは読み取り専用ではなく、値を代入した場合はlocation.hrefへの代入と同様にページの遷移が発生していました。

こういった、詳細がよくわからないIE独自の機能は、これまでも脆弱性の原因や攻撃の起点となることが多かったので、ブラウザ独自の機能は可能な限り使用を避けたほうがいいでしょう。

URLに認証情報が含まれている場合の注意点

document.documentURIは、location.hrefと同様に現在のドキュメントのURL全体を取得できますが、Basic認証の認証情報をURLに含めてドキュメントにアクセスした場合の挙動が異なります。具体的には、location.hrefにはユーザー名とパスワードは含まれませんが、document.documentURIにはブラウザによっては認証情報が含まれた値が格納されるという違いがあります。

たとえば、http://user:pass@example.jp/というURLへアクセスした場合、example.jpというサイトへユーザー名user⁠、パスワードpassという認証情報を使ってBasic認証経由でアクセスされることになりますが、ChromeやFirefoxではこのときURLが以下のように異なります。

  • location.href → http://example.jp/(認証情報を含まないURL)
  • document.documentURI → http://user:pass@example.jp/(ユーザー名およびパスワードを含んだURL)

「Basic認証の認証情報が含まれるURLへ攻撃者が被害者を誘導することで、XSSを発動させられる」という問題が、過去に何度か報告されています[1]⁠。また、一般的には、認証情報を含めたURLへアクセスする必要性はありません。以上のことから、URLに認証情報が含まれている場合には「認証情報が含まれないようページを再ロードはする」⁠以降の処理を中止する」などの予防措置をとっておくといいでしょう。以下、URLに認証情報が含まれるときは強制的に認証なしのURLをリロードするコードの例です。

if( document.documentURI && location.href !== document.documntURI ){
    // URLに認証情報が含まれるときは強制的に認証なしのURLをリロードする
    location.href = location.href;
}

URLオブジェクト

たとえば、引数としてリソースのURLを示す文字列が与えられた関数において、そのリソースのオリジンが現在のドキュメントと同一オリジンである場合にのみtrueを返し、そうでない場合にはfalseを返すといった処理をJavaScriptで書く場合、どのようなコードを書けばいいでしょうか。

URLとしては、⁠http://example.jp/foo」といった絶対URLだけでなく、⁠/bar/text.txt」⁠text2.txt」のような相対URLも渡される可能性があります。

function isSameOrigin( url ){
    if( /* urlの指すリソースが同一オリジンのものなら */ ){
        return true;
    }
    return false;
}

相対URLの落とし穴

まず考えられるのは、引数urlを検査し、

  • 「先頭に英数字、続けて『:』があればスキーマを持つので、絶対URL」
  • 「絶対URLでも、先頭が現在のオリジンと同一であれば、trueを返す」
  • 「絶対URLでなければ相対URLであるので、trueを返す」

のようなコードを書くことです。

// bad code
function isSameOrigin( url ){
    var site = location.origin + "/";
    if( url.match( /^[\w]+:/ ) ){
        // urlは「http:」「https:」や「javascript:」などのスキーム名を持つ絶対URL
        if( site === url.substr( 0, site.length ) ){
            // 現在のドキュメントのオリジンと同一オリジンを持つ絶対URL
            return true;
        }
    }else{
        // スキームを持たないので相対URL
        return true;
    }
    return false;
}

この方法は、一見正しく思えるかもしれませんが、かんたんに検査を迂回することができます。

じつは、相対URLというものは、同一オリジン内だけでなく、⁠//」から始めることによって、現在のプロトコルと同じプロトコルの相対URLであると解釈されます。すなわち、攻撃者が引数urlに「//attacker.example.com/」のような文字列を与えた場合、このコードでは攻撃者のサイトであるhttp://attacker.example.com/を同一オリジンだと判断してしまうのです。

では、⁠先頭が//であればプロトコル相対URLである」といった判断を追加するのはどうでしょうか。そうすると、今度は攻撃者は「/\attacker.example.com/」のようなURLを与えます。これはRFCには違反したURLですが、一部のブラウザではURL先頭の「/\」「//」と等価に扱うため、攻撃者のサイトであるhttp://attacker.example.com/を同一オリジンだと判断してしまいます。

このように、検査を厳密に行おうとすると、RFCに従ってURLの仕様を知り尽くし、さらに各ブラウザごとの挙動の差までも把握していなければなりません。一方で、攻撃者はこういった複雑な仕様やブラウザの挙動差を丹念に調べ知り尽くしているので、このようなURLを自力でパースして検査するというアプローチでは対策は非常に困難なものになります。

ブラウザの機能でURLを絶対URLに変換する

そこで、URLをパースするコードは自分では書かずに、ブラウザの機能、すなわちDOMの機能を使って、与えられたURLをいったん絶対URLに変換してしまうのがいいでしょう。

現在の多くのブラウザでは、URLコンストラクタを呼び出すことで、locationオブジェクトと同じインタフェースを持つURLオブジェクトを生成できます。これを利用します。

var url = new URL( "http://example.jp/foo" );
console.log( url.href );        // "http://example.jp/foo"
console.log( url.protocol );    // "http:"
console.log( url.hostname );    // "example.jp"

相対URLを表す文字列を絶対URLに変換する場合には、URLコンストラクタの第2引数に、ベースとなるURLを与えます。

var url = new URL( "/foo", "http://example.jp/" );
console.log( url.href );        // "http://example.jp/foo"

url = new URL( "../foo", "http://example.jp/bar/baz.html" );
console.log( url.href );        // "http://example.jp/foo"
console.log( url.origin );      // "http://example.jp"

このように、URLコンストラクタを介してURLオブジェクトを生成すれば、文字列としてURLを操作することなく、相対URLを絶対URLに変換したり、そのオリジンを取り出したりできます。

function isSameOrigin( url ){
    var url = new URL( url, location.href );
    if( url.origin === location.origin ){
        return true;
    }
    return false;
}

IEへの対応

URLコンストラクタの代わりに<a>要素を用いる

Internet ExplorerではURLコンストラクタは使用できませんが、代わりに<a>要素を用いることで相対URLと絶対URLの変換を行うという方法が知られています。

<a>要素のhref属性に「/foo」のような相対URLを設定した状態で、その要素に対して以下のようにhref属性を取得すると、設定されている相対URLである「/foo」が返されます。

elm.getAttribute( "href" )

一方、elm.hrefのようにhrefプロパティを参照した場合には、⁠http://example.jp/foo」のような絶対URLが取得できます。こうした<a>要素の挙動を利用することで、ブラウザ自身の機能を使って絶対URLに変換することができるのです。

以下のコードでは、新たに生成した<a>要素のhrefプロパティにURLを設定することで、与えられたURLを強制的に絶対URLに変換しています。

function getAbsoluteUrl( url ){
    var elm;
    elm = document.createElement( "a" );
    elm.setAttribute( "href", url );
    return elm.href;
}

hrefプロパティに相対URLを設定したときに、href以外の各プロパティが正しく取得できない問題を解決する

<a>要素は、絶対URLを指すhrefプロパティだけでなく、locationオブジェクトやURLオブジェクトと同様に、protocolやhost、hostnameといった、URLの部分文字列を指すプロパティもサポートしています。これらのプロパティを用いて、URLをパースして各部分を取り出すことを考えてみましょう。

IEにて、以下のコードのように新たに生成した<a>要素のhrefプロパティにURLを代入し、protocolやhost、hostnameなどのプロパティを読みだしてみます。

// bad code
var elm = document.createElement( "a" );
elm.setAttribute( "href", "http://example.jp/fo" );
console.log( elm.protocol ); // "http:"
console.log( elm.host );     // "example.jp"
console.log( elm.hostname ); // "example.jp"

想定したとおりに正しく動いているように見えるかもしれません。しかし、じつはIEでは、hrefプロパティに相対URLを設定したときに、href以外の各プロパティが正しく取得できないという問題があります。

// bad code
var elm = document.createElement( "a" );

elm.setAttribute( "href", "/foo" );
console.log( elm.protocol ); // IEでは空文字列になる
console.log( elm.host );     // IEでは空文字列になる
console.log( elm.hostname ); // IEでは空文字列になる

elm.setAttribute( "href", "//example.jp/foo" );
console.log( elm.protocol ); // IEでは空文字列になる
console.log( elm.host );     // "example.jp"
console.log( elm.hostname ); // "example.jp"

そこで、IEで<a>要素などを用いてURLを取り扱うという場合には、相対URLをいったん絶対URLに変換する必要があります。相対URLを絶対URLに変換するコードは、先ほどのものがそのまま利用できます。

function getAbsoluteUrl( url ){
    var elm = document.createElement( "a" );
    elm.setAttribute( "href", url );
    return elm.href;
}

// 現在のドキュメントが http://example.jp/ だとする
var url = "/foo";
var elm = document.createElement( "a" );
elm.setAttribute( "href", getAbsoluteUrl( url ) ); // URLを絶対URLに変換しhref属性に設定
console.log( elm.href );     // "http://example.jp/foo"が表示される
console.log( elm.protocol ); // "http:"が表示される
console.log( elm.hostname ); // "example.jp" が表示される

相対URLを絶対URLに変換する際に、ベースとなるURLを明示的に指定できない問題を解決する

さらに、<a>要素を用いて相対URLを絶対URLに変換する際には、常に現在のURL(location.href)を起点として変換されてしまい、ベースとなるURLを明示的に指定できないという問題もあります。

これを解決するためには、以下のような方法を採ります。

  1. IE11ではDOMParserを、IE10ではdocument.implementation.createHTMLDocumentを用い、現在表示されているコンテンツとは切り離されたDOMツリーを作成する
  2. そのDOMツリーに<base>要素を追加し、ベースURLを指定する
  3. そのDOMツリー内で前述の<a>要素を使った方法を用いて相対URLを絶対URLに変換する

具体的なコードは以下のようになります。

var createUrl = (function() {
    var doc;

    try {
        new URL("/", "http://example.jp/"); // URLコンストラクタが使えるかのテスト
    } catch (e) {
        try {
            doc = (new DOMParser).parseFromString("<html><head></head><body></body></html>", "text/html");
        } catch (e) {
            doc = document.implementation.createHTMLDocument("");
        }
    }

    return function (url, base) {
        var d = document, baseElm, aElm, result;
        if (doc === undefined) {
            if (base === undefined) {
                base = location.href;
            }
            result = new URL(url, base);
        } else {
            // URLコンストラクタが使えないため<a>要素を使ってURLを解決する
            if (base !== undefined) {
                // baseが指定されている場合は現在のdocumentとは切り離されたHTMLDocument内に<base>要素と<a>要素を設定
                d = doc; 
                while (d.head.firstChild) d.head.removeChild(d.head.firstChild);
                baseElm = d.createElement("base");
                baseElm.setAttribute( "href", base );
                d.head.appendChild( baseElm );
            }
            aElm = d.createElement("a");
            aElm.setAttribute("href", url);
            aElm.setAttribute("href", aElm.href);
            //d.appendChild(aElm);

            result = {
                protocol: aElm.protocol,
                host: aElm.host,
                hostname: aElm.hostname,
                port: aElm.port,
                pathname: aElm.pathname,
                search: aElm.search,
                hash: aElm.hash,
                href: aElm.href,
                username: "",
                password: "",
                origin : aElm.origin || null
            };
            if (result.protocol === "http:" && result.port === "80") {
                // httpかつデフォルトポートの場合はポート番号の"80"を削除
                result.port = "";
                result.host = result.host.replace( /:80$/, "" );
            }else if (result.protocol === "https:" && result.port === "443") {
                // httpsかつデフォルトポートの場合はポート番号の"443"を削除
                result.port = "";
                result.host = result.host.replace( /:443$/, "" );
            }
            if (result.protocol === "http:" || result.protocol === "https:") {
                if (result.pathname && result.pathname.charAt(0) !== "/") {
                    // pathnameの先頭の"/"が付与されないバグへの対応
                    result.pathname = "/" + result.pathname;
                }
                if (!result.origin) {
                    result.origin = result.protocol + "//" + result.hostname + (result.port ? ":" + result.port : "");
                }
            }
        }
        if (result.username || result.password) {
            // URLにBasic認証のユーザ名、パスワードが含まれている場合は例外を発生させる
            throw new URIError( result.username || result.password );
        }
        return result;
    };
})();

var url = createUrl( "/foo?bar", "http://example.jp/baz" );
console.log( url.href );    // "http://example.jp/foo?bar"
console.log( url.search );  // "?bar"

このcreateUrlという関数は、これまで説明したとおりURLコンストラクタが利用可能な場合にはそれを利用し、利用できない場合には<a>要素を使いつつ、相対URLの場合にはDOMParserまたはcreateHTMLDocumentを用いて絶対URLに変換します。そして、戻り値として、locationやURLオブジェクトと同様に、URLをパースした結果を各プロパティにセットしたオブジェクトを生成して返します。

また、先に説明したように、URLにBasic認証のユーザー名やパスワードが含まれている場合にはセキュリティ上の問題を引き起こすことがあるので、ユーザー名やパスワードが含まれるURLの場合には例外を発生するようになっています。

まとめ

今回は、URLを文字列として自前でパースするのではなく、ブラウザの機能を利用してパースする方法を解説しました。このような方法をとることで、少ないコードで、実際のブラウザが解釈するとおりのURLのパース結果を得ることができます。

繰り返しになりますが、JavaScriptのコード上において、URLを基準としてセキュリティ上の判断を行う場面は、開発者が意図していない場合も含めると、比較的頻繁に発生します。そういった場面で、URLを文字列として取り扱い、自身でURLをパースするコードを書くことは、多くの場合に脆弱性を発生させる原因になります。よほどの理由がない限り、URLをパースするには、自身でコードを書くのではなく、ブラウザの機能を用いるようにしましょう。

また、今回説明したようなURLのパース時の問題以外にも、JavaScript上のコードからURLを操作する際にlocationオブジェクトなどを通じてセキュリティ上の問題が発生することがよくあります。locationオブジェクトの操作に起因する脆弱性としてよく見かけるのは以下のようなものです。

  • location.hrefにjavascript:alert("xss");やvbscript:MsgBox "xss"といったスクリプトのスキームを代入されることによりXSSが発生する
  • HTML内にlocation.hrefやlocation.hashを表示する際に、URL内に含まれる「<」⁠>」などがエスケープされていないことが原因でXSSが発生する

このような脆弱性の具体例や対策の詳細についても、今後回を追って取り上げたいと思います。

おすすめ記事

記事・ニュース一覧