ウェブサイトやウェブアプリを開発していると、新たにブラウザーウィンドウやタブを開いて、元のタブと連携したいと考えたことはないでしょうか。または、HTML の iframe
要素を使って組み込んだコンテンツと連携したいと考えたこともあるでしょう。今回は、このようなコンテンツの連携をクロスオリジンで実現するために必要な Cross-document messaging について解説します。
クロスオリジンのウィンドウの制約
近年のブラウザーは、セキュリティーの観点から、異なるサイト(正確に言うとクロスオリジン)となる Window
オブジェクトがお互いに影響を与えることができないように制限しています。具体的に言うと、例えば、JavaScript から新たにウィンドウをポップアップしたとしましょう。次のサンプルコードをご覧ください。
<button onclick="openWin()">Open</button>
<script>
function openWin() {
const opts = 'width=500,height=300';
const win = window.open('child.html', null, opts);
win.onload = () => {
const h1 = win.document.querySelector('h1');
h1.textContent = '親から子を書き換えました';
};
}
</script>
このサンプルは “Open” ボタンを押すと、同じサイトにある child.html
を小ウィンドウとしてポップアップ表示します。そして、子ウィンドウのドキュメントが読み込まれたら、子ウィンドウの h1
要素のテキストを書き換えます。
このコードの重要なところは、window.open()
メソッドから得られる WindowProxy
オブジェクト(変数 win
)です。このオブジェクトは、新たに開いたウィンドウの Window
オブジェクトを表しており、そこから Document
オブジェクトにアクセスすることができます。つまり、親は子のコンテンツを操作し放題ということです。
次に、child.html
では次の JavaScript コードが実行されるとします。
const doc = window.opener.document;
doc.querySelector('h1').textContent = '子から親を書き換えました。';
この子ウィンドウの JavaScript によって、親ウィンドウの h1
要素のテキストも書き換えられます。子ウィンドウでは、window.opener
が親ウィンドウの WindowProxy
オブジェクトで、その document
プロパティが親ウィンドウの Document
オブジェクトを表します。このように同じサイト内で読み込まれたコンテンツは、お互いに JavaScript から書き換えることができます。
ところが、もし親ウィンドウ側で、子ウィンドウのコンテンツとして別のサイト(クロスオリジン)の URL を指定したとしましょう。
const win = window.open('https://b.example.jp/child.html', null, opts);
この場合は、前述のサンプルは動作しません。なぜなら、親ウィンドウで window.open()
メソッドから得られる WindowProxy
オブジェクトは onload
イベントハンドラをサポートしないからです。さらに、WindowProxy
オブジェクトには document
プロパティが存在しません。そのため、子ウィンドウのコンテンツを書き換えるどころが、読み取ることすらできません。
これは子ウィンドウ側でも同様です。子ウィンドウ側では window.opener
に document
プロパティが存在しないのです。
このように、クロスオリジンの Window
オブジェクト同志は、お互いに一切の影響を与えることができないようブロックされます。
Cross-document messaging が作られた経緯
前述の通り、クロスオリジンの Window
オブジェクト同志ではお互いにコンテンツの読み書きができません。この制約は、クロスオリジンのコンテンツは信頼できないことが前提になっているからです。子ウィンドウの URL が親ウィンドウと全く異なるドメインなら分からなくもありませんが、ホスト名だけ異なる場合もクロスオリジンになります。
例えば、親ウィンドウの URL が https://example.jp
だったとしましょう。そして子ウィンドウに https://child.example.jp
のコンテンツを読み込みたいとします。どちらのホストも同じ運営者によって運営されており信頼できるはずなのですが、前述の制約がなくなることはありません。そこで考え出されたのが Cross-document messaging です。
Cross-document messaging は、HTML5 が流行っていたころ、つまり、W3C にて HTML5 仕様が策定されていたころは HTML5 Web Messaging と呼ばれていました。現在は、WHATWG の HTML 仕様の一部として Cross-document messaging という名称で仕様が定義されています。
クロスサイトの Window
オブジェクト同志でお互いにコンテンツの読み書きができてしまうことは、セキュリティー上、非常に危険です。Cross-document messaging は、直接的なコンテンツアクセスを禁止する代わりにメッセージのやり取りの仕組みを取り入れることでセキュリティー上のリスクを解決します。
Window
オブジェクト同志はメッセージのやり取りしかできないため、親が一方的に子のコンテンツを書き換えることはできませんし、その逆もしかりです。これでクロスオリジンのセキュリティー上のリスクが大きく低減します。
相手のウィンドウを表す WindowProxy オブジェクト
Window
オブジェクト同志でメッセージを送るためには、相手のウィンドウを表す WindowProxy
オブジェクトを取り出す必要があります。WindowProxy
は window.open()
メソッドだけでなく、iframe
要素や object
要素からも取り出すことができます。
window.open() メソッドで新たに開いたウィンドウ
前述の説明のおさらいになりますが、window.open()
メソッドは、開いた子ウィンドウの WindowProxy
オブジェクトを返します。
const win = window.open('https://b.example.jp/child.html', null, opts);
一方、子ウィンドウ側では、window.
が親ウィンドウの opener
WindowProxy
オブジェクトを表します。
iframe 要素によって生成されたコンテンツ
iframe
要素によって組み込まれたコンテンツも WindowProxy
オブジェクトを持ちます。iframe
要素の DOM オブジェクトの contentWindow
プロパティから得ることができます。
<iframe src="https://b.example.jp/child.html"></iframe>
<script>
const win = document.querySelector('iframe').contentWindow;
</script>
一方、子ウィンドウ側では、window.parent
が親ウィンドウの WindowProxy
オブジェクトを表します。
object 要素によって生成されたコンテンツ
object
要素によって組み込まれたコンテンツも WindowProxy
オブジェクトを持ちます。object
要素の DOM オブジェクトの contentWindow
プロパティから得ることができます。
<object data="https://b.example.jp/child.html"></object>
<script>
const win = document.querySelector('object').contentWindow;
</script>
一方、子ウィンドウ側では、window.parent
が親ウィンドウの WindowProxy
オブジェクトを表します。
親からメッセージを送信して子で受信する方法
Window
オブジェクト間でメッセージを送信するには、相手側のウィンドウを表す WindowProxy
オブジェクトの postMessage()
メソッドを使います。そして、受信側では自身のウィンドウを表す Window
オブジェクトで発生する message
イベントのリスナーをセットします。
次の例は、HTML に iframe
要素がマークアップされていますが、その src
属性にはクロスオリジンとなる URL がセットされています。ここでは、親のページのオリジンを https://a.example.jp
、子となるページ(iframe
要素に組み込まれるページ)のオリジンを https://b.example.jp
とします。
まず親となるページの HTML と JavaScript は次の通りです。
<iframe src="https://b.example.jp/child.html"></iframe>
<script>
window.addEventListener('load', () => {
const win = document.querySelector('iframe').contentWindow;
win.postMessage('Hello', 'https://b.example.jp');
}, false);
</script>
この親ページでは、ページのロードが完了したら、iframe
要素のコンテンツのウィンドウを表す WindowProxy
オブジェクトを取得し、その postMessage()
メソッドでメッセージを送信しています。postMessage()
メソッドの第一引数にはメッセージとなる値を、第二引数には送信先のオリジンを指定します。
第一引数に指定するメッセージは文字列でもオブジェクトでも構いません。もしオブジェクトの場合は、そのクローンが相手に送られることになります。クローン処理には構造化複製アルゴリズムが使われます。Array
や Object
オブジェクトだけでなく Date
, Blob
, File
オブジェクトなども複製できます。
第二引数の送信先のオリジンは、もし分からなければ *
を指定することもできます。
win.postMessage('Hello', '*');
では、受信側となる iframe
要素に組み込まれたページの JavaScript を見てみましょう。
window.addEventListener('message', (event) => {
console.log(event.data); // "Hello"
}, false);
Window
オブジェクト(変数 window
)で発生する message
イベントをリッスンします。コールバック関数には MessageEvent
オブジェクトが引き渡されます。ここでは Cross-document messaging に関連するプロパティに絞って紹介します。
プロパティ名 | 型 | 説明 |
---|---|---|
data | any | メッセージを表すデータ。型は送信側で何を送ったか次第。 |
origin | String | 送信元のオリジン(例:"https://a.example.jp" ) |
source | WindowProxy | 送信元のウィンドウを表す WindowProxy オブジェクト |
子からメッセージを送信して親で受信する方法
前述の例とは逆方向にメッセージを送ってみましょう。やり方はほとんど同じですので、ある程度は予想できると思います。
まず親ページは次のようになります。
<iframe src="https://b.example.jp/child.html"></iframe>
<script>
window.addEventListener('message', (event) => {
console.log(event.data); // "Hello"
}, false);
</script>
親ページではメッセージを受ける側になるため、自身のウィンドウを表す Window
オブジェクト(変数 window
)で message
イベントのリスナーをセットします。
メッセージを送信する側の子ウィンドウのスクリプトは次のようになります。
window.parent.postMessage('Hello', 'https://a.example.jp');
子から見て親のウィンドウを表す WindowProxy
オブジェクトは window.parent
です。その postMessage()
メソッドを使って、親にメッセージを送っています。第二引数には、送信先となる親ウィンドウのオリジンを指定します。
メッセージの送信元に返信する方法
これまでのサンプルは、ウィンドウが親と子しかありませんでした。つまり通信相手となるウィンドウはどちらから見ても必ず一つです。しかし、親から見て子が複数存在する場合もあるでしょう。その場合、親の Window
オブジェクトは複数の子からのメッセージを受け取ることになります。
子からのメッセージに返信したい場合は、該当の子の WindowProxy
オブジェクトを割り出す必要が出てきます。子は親が生成したはずなので、親は子の WindowProxy
オブジェクトを知りえますが、もし返信しようとしたら、それを管理しておく必要があります。少し面倒ですね。
もし返信したいだけなら、MessageEvent
オブジェクトの source
プロパティから送信元のウィンドウの WindowProxy
オブジェクトを取得できます。また、postMessage()
メソッドの第二引数に指定するべきオリジンは MessageEvent
オブジェクトの origin
プロパティから得ることができます。
window.addEventListener('message', (event) => {
event.source.postMessage('Hi', event.origin);
}, false);
セキュリティー
Cross-document messaging ではクロスオリジンのウィンドウを扱います。したがって、基本的に相手となるウィンドウのコンテンツを信用するべきではありません。また、自身が書いたコードにバグが潜む可能性も否めません。そのため、Cross-document messaging にはいくつかの防止手段が用意されていますので、可能な限りそれらを使いましょう。
送信先のオリジン指定
前述の通り、WindowProxy
オブジェクトの postMessage()
メソッドの第二引数に宛先のウィンドウのオリジンを指定します。これにより予期せぬ宛先にメッセージが送信されることがなくなります。*
を指定することも可能ですが、理由がない限り *
を指定することは避けるべきです。
送信元のオリジンのチェック
メッセージを受信する際にも、可能な限り、MessageEvent
オブジェクトの origin
プロパティを使って送信元のオリジンをチェックするようにしましょう。前述の例では説明を優先するために送信元のオリジンのチェックは行いませんでしたが、実際には次のようにオリジンをチェックすることになるでしょう。
window.addEventListener('message', (event) => {
if(event.origin === 'https://a.example.jp') {
// メッセージに基づいた処理を行う
} else {
// 何もしないか、エラーを出力する
}
}, false);
受信データのフォーマットのチェック
受け取ったメッセージが期待通りのフォーマットのデータなのかについても考慮が必要です。クロスオリジンの場合は、基本的に受け取ったメッセージは安全ではないと考えるべきです。例えば、次のコードは危険です。
window.addEventListener('message', (event) => {
if(event.origin === 'https://a.example.jp') {
document.getElementById('count').innerHTML = event.data;
}
}, false);
受信データが数字だけのテキストであることが保証されているなら問題ありませんが、もし送信元が悪意のある第三者に乗っ取られた場合、数字だけのテキストが送られてくるとは限りません。したがって、受信データが本当に数字だけで構成されているのかをチェックするようにしましょう。
次のコードでは受信データが数字だけで構成されているかをチェックするだけでなく、桁数の上限を 10 桁に制限しています。
window.addEventListener('message', (event) => {
if(event.origin === 'https://a.example.jp') {
if(/^\d{1,10}$/.test(event.data)) {
document.getElementById('count').innerHTML = event.data;
}
}
}, false);
まとめ
恐らく、まったく関係がないウェブサイトのコンテンツを iframe
要素で組み込んだり、window.open()
メソッドでウィンドウを開くことはないでしょう。Cross-document messaging が最も使われるのは、同じ管理者によるサイトでありながらも、オリジンが異なる場合ではないでしょうか。
自社サービスの規模が大きくなり、機能ごとにサーバーを分けるシステム構成になった場合、Cross-document messaging を活用するときがやってくるのかもしれませんね。
今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。