【JavaScript】クロスオリジンのウィンドウ間でメッセージを送受信する Cross-document messaging

ウェブサイトやウェブアプリを開発していると、新たにブラウザーウィンドウやタブを開いて、元のタブと連携したいと考えたことはないでしょうか。または、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.openerdocument プロパティが存在しないのです。

このように、クロスオリジンの 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 オブジェクトを取り出す必要があります。WindowProxywindow.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() メソッドの第一引数にはメッセージとなる値を、第二引数には送信先のオリジンを指定します。

第一引数に指定するメッセージは文字列でもオブジェクトでも構いません。もしオブジェクトの場合は、そのクローンが相手に送られることになります。クローン処理には構造化複製アルゴリズムが使われます。ArrayObject オブジェクトだけでなく 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 に関連するプロパティに絞って紹介します。

プロパティ名説明
dataanyメッセージを表すデータ。型は送信側で何を送ったか次第。
originString送信元のオリジン(例:"https://a.example.jp"
sourceWindowProxy送信元のウィンドウを表す 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 を活用するときがやってくるのかもしれませんね。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share