はじめに
ブラウザの「同一生成元ポリシー(Same-Origin Policy)」は、ウェブの安全を守る鉄の掟です。
しかし、社内ツールや独自の開発支援ツールを作っていると思い至ります。「コンテキストは外部サイト(Parent)に置きつつ、UIや高度な処理は自作のSPA(Child)でやりたい!」という場面に遭遇します。
ブラウザ拡張機能を作るのも手ですが、配布や権限の管理が意外と面倒だったりします(サボりたいわけではありませんが)。
そこで今回は自前の Sub Window (SPA) を開き、postMessage API を活用して安全にドメインの壁を超える手法の第一歩として、「PostMessageによる疎通」 と 「Heartbeatによる生存確認」 について解説します。
シリーズ記事
| № | 記事 | 内容 |
|---|---|---|
| 1 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(前編):Cross-Domain通信の仕組み | 本編 |
| 2 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(中編):RPCによる関数の遠隔実行 | 関数シリアライズによるドメイン跨ぎのRPCメカニズム |
| 3 | [悪用禁止] 外部サイトのDOMを自前SPAで操る(後編):Node.jsとTerserによる自動化と配備 | Terserを利用したビルド自動化とBookmarkletによる配備 |
🚀 技術の全体像
通常、異なるドメイン間では直接的なDOM操作やメモリへのアクセスは禁止されています。
本手法では、Vanilla JS(基本的なJavaScript)のみを用いて以下のステップで安全な通信を確立します。
-
Browser Console: 外部サイト上でスクリプトを実行し、SPA (
http://localhost:3000) を子画面として開く。 - Origin Passing: 開く際にParent側のオリジン情報をSPAに伝える。
- Heartbeat: 双方が接続状態を定期的に確認し、通信を維持する。
方法
1. Originの特定によるセキュアな通信路の確立
postMessage を使用する際、宛先オリジン(targetOrigin)に *(ワイルドカード)を指定することは推奨されません。
通信相手のウィンドウが意図せぬドメインへ遷移した場合、機密情報を含むメッセージが第三者へ渡るリスクがあるためです。
また、近年のブラウザセキュリティ(Cross-Origin Isolationなど)の仕様において、一部のリソース(SharedArrayBuffer等)を扱うサイトでは * による指定自体が制限され、通信が成立しないケースも存在します。
正確な targetOrigin を指定することで、ブラウザは「現在のウィンドウのオリジンが指定したものと一致する場合のみ」メッセージを配信するようになり、情報の誤送信を物理的に防ぐことが可能です。
本手法では、コンソールからSPAを開く、Parent側の location.origin を明示的に受け渡すことで、このセキュリティ要件に対応します。
サンプルコード (Parent: Browser Console)
const parentDomain = window.location.origin;
// SPAのURLにハッシュやパラメータとしてParentのオリジンを付与
const spaUrl = `http://localhost:3000/#parentDomain=${encodeURIComponent(parentDomain)}`;
const windowName = "Cross-Domain-RPC-Demo";
// 同じ名前を指定することで、常に一つのSub Windowを再利用・フォーカス
const subWin = window.open(spaUrl, windowName);
if (subWin) {
subWin.focus();
}
なぜこれが重要か?
SPA側は window.location.hash などから「自分がどのサイトから呼ばれたか」を一意に特定できます。
これにより、メッセージを送り返す際に event.source.postMessage(data, targetOrigin) の targetOrigin を厳密に指定でき、情報の漏洩を防げます。
2. Heartbeatによる接続状況の監視
ウィンドウが閉じられたりページが遷移したりした場合、通信は一方的に途絶えてしまいます。
SPA(Child)からParentへ定期的に HEARTBEAT_PING を送り、Parentからの HEARTBEAT_PONG を受け取ることで、双方が「接続中(Connected)」であることを正確に把握します。
SPA側 (Child) の受信・送信ロジック
Child側では、Parentからの返信を受け取って初めて isConnected を真(true)と判断します。
// index.js (Child)
let isConnected = false;
let lastPongTime = Date.now(); // 最後にPONGを受け取った時刻
const targetOrigin = new URLSearchParams(window.location.hash.slice(1)).get('parentDomain');
// Parentからの返信(PONG)を待機
window.addEventListener("message", (event) => {
if (event.data.type === 'HEARTBEAT_PONG') {
isConnected = true;
lastPongTime = Date.now(); // 受信時刻を更新
console.log("Status: Connected to Parent");
}
});
// 1秒ごとに送信および死活監視
setInterval(() => {
// タイムアウト判定 (5秒以上PONGがない場合は切断とみなす)
if (Date.now() - lastPongTime > 5000) {
isConnected = false;
console.warn("Status: Disconnected (Timeout)");
}
if (window.opener && targetOrigin) {
window.opener.postMessage({ type: 'HEARTBEAT_PING' }, targetOrigin);
}
}, 1000);
Parent側 (Console) の応答ロジック
Parent側は、ChildからのPINGを受け取った直後に、同一の通信路(event.source)に対して返信を行います。
// ConsoleSnippet (Parent)
window.addEventListener("message", (event) => {
// セキュリティチェック:許可したオリジン(localhost:3000)以外は無視
if (event.origin !== "http://localhost:3000") return;
if (event.data.type === 'HEARTBEAT_PING') {
// 受け取った相手(Child)に即座にPONGを返す
event.source.postMessage({ type: 'HEARTBEAT_PONG' }, event.origin);
}
});
おわりに(前編)
ここまでの手順で、JSを用いて「Originの特定」と「双方向の生存確認(Heartbeat)」を備えた安定的な通信路が確立できました。
次回の「中編」では、この通信路を拡張し、「Parent側の関数をChildから遠隔実行する(RPC)」 高度な実装について詳しく見ていきます。