iframe の中から parent.postMessage(msg, targetOrigin) を呼ぶとき、targetOrigin が親ページのオリジンと違うと、メッセージは捨てられます。例外は投げられず、postMessage() は普通に戻ってきます。残るのは、開発者ツールのコンソールの警告が1行だけです。
別のポートで親ページ(http://127.0.0.1:8801)と iframe(http://127.0.0.1:8802)を用意し、iframe から親へ、targetOrigin を変えて送りました。Playwright 経由の Chromium 141.0.7390.37 です。
targetOrigin |
例外 | 親に届いたか | コンソール |
|---|---|---|---|
http://127.0.0.1:8802(iframe 自身のオリジン) |
なし | 届かない | 警告1行 |
http://127.0.0.1:8801(親のオリジン) |
なし | 届いた | なし |
* |
なし | 届いた | なし |
http://127.0.0.1:8801/some/path?x=1 |
なし | 届いた | なし |
http://127.0.0.1:8801/ |
なし | 届いた | なし |
http://localhost:8801(ホスト名だけ違う) |
なし | 届かない | 警告1行 |
https://127.0.0.1:8801(スキームだけ違う) |
なし | 届かない | 警告1行 |
/ |
なし | 届かない | 警告1行 |
not-a-url |
SyntaxError | 届かない | なし |
警告の文はこうです。
Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://127.0.0.1:8802') does not match the recipient window's origin ('http://127.0.0.1:8801').
/ は「送る側と同じオリジン」という意味なので、iframe 自身のオリジンを書いたのと同じ結果でした。パスやクエリは無視され、比べられるのはスキーム・ホスト・ポートだけです。オリジンとして読めない文字列だけは、例外になりました。
先に断っておくと、targetOrigin が一致しないメッセージを送らないのは、HTML の仕様どおりの動きです。MDN の postMessage() のページにも、一致しなければイベントは送られないと書かれています。ここで書くのは、自作の WordPress プラグインでこれを踏んで、閉じるボタンが別のサイトでだけ効かなかった話と、targetOrigin の書き方ごとに測った結果です。
何が起きていたか
自作の Rapls AI Chatbot には、チャットを別のサイトに埋め込むためのスクリプトがあります。埋め込むと、チャットは iframe の中に入ります。チャットの × ボタンを押すと、iframe から親ページに「閉じて」とメッセージを送り、親ページが iframe を隠します。
1.19.1 まで、その送り方はこうでした。
var targetOrigin = this.config.embed_origin || window.location.origin;
window.parent.postMessage({type: 'raplsaich:close'}, targetOrigin);
embed_origin が無いときは、window.location.origin を使います。でも、iframe の中の window.location.origin は、iframe 自身のオリジン(チャットを置いている WordPress のサイト)です。
同じサイトの中に埋め込んだときは、親と iframe のオリジンが同じなので、届きます。別のサイトに埋め込むと、表の1行目と同じことになり、メッセージは捨てられます。例外が出ないので、× ボタンは「押しても何も起きない」だけでした。
1.19.2 の直し方
親ページのオリジンを、iframe の URL に付けて渡すようにしました。
// 埋め込み用スクリプト(親ページ側)
iframe.src = siteUrl + '/?raplsaich_embed=1&raplsaich_parent=' +
encodeURIComponent(window.location.origin);
iframe を返す PHP 側で、それがオリジンの形をしているかを確かめてから、targetOrigin に使います。形が違えば * にします。
$embed_parent = isset($_GET['raplsaich_parent']) ? sanitize_text_field(wp_unslash($_GET['raplsaich_parent'])) : '';
$embed_origin = preg_match('#^https?://[A-Za-z0-9.\-]+(:\d{1,5})?$#', $embed_parent) ? $embed_parent : '*';
var targetOrigin = this.config.embed_origin || '*';
* に逃がしてよいのは、送る中身が「閉じて」「準備できた」という合図だけで、知られて困るものを含まないからです。受け取る親ページの側でも、event.origin がチャットのサイトであることを確かめてから動きます。秘密を含むメッセージなら、* にしてはいけません。
親のオリジンを知る方法
URL で渡す以外にも、iframe の中から親のオリジンを知る方法はあります。同じ構成で読んでみました。
document.referrer // "http://127.0.0.1:8801/"
location.ancestorOrigins[0] // "http://127.0.0.1:8801"
location.origin // "http://127.0.0.1:8802"(iframe 自身)
親ページの URL には ?secret=1 を付けていましたが、document.referrer にはオリジンと / しか入っていませんでした。Chromium の既定の Referrer-Policy(strict-origin-when-cross-origin)で、別オリジンにはオリジンだけが渡るためです。親ページが Referrer-Policy を no-referrer にしていれば、空になります。location.ancestorOrigins は、ブラウザによって使えないものがあります(今回測ったのは Chromium だけです)。どちらも、いつでも頼れるわけではないので、埋め込むスクリプトの側から明示的に渡すことにしました。
postMessage を使うとき
-
targetOriginが一致しないメッセージは、例外なく捨てられる。コンソールに警告が1行出るだけ - iframe の中の
window.location.originは、iframe 自身のオリジン。親のオリジンではない - 比べられるのはスキーム・ホスト・ポート。パスは無視される。
localhostと127.0.0.1は別物 -
/は「自分と同じオリジン」。iframe から親に送ると、別オリジンなら届かない - 親のオリジンは、埋め込む側から渡すのがいちばん確実。受け取る側は
event.originを必ず確かめる -
*は、知られて困らない合図にだけ使う
あなたの iframe は、親ページに「どこへ」送っていますか。
手元で試すコマンド
PHP のビルトインサーバーを2つ立てて、Playwright で送ります。
<!-- a/index.html(親ページ。8801 で配る) -->
<!doctype html><meta charset="utf-8">
<script>
window.got = [];
addEventListener('message', e => window.got.push({ data: e.data, origin: e.origin }));
</script>
<iframe id="f" src="http://127.0.0.1:8802/frame.html"></iframe>
<!-- b/frame.html(iframe。8802 で配る) -->
<!doctype html><meta charset="utf-8">
<script>
window.trySend = (label, target) => {
try { parent.postMessage(label, target); return 'sent'; }
catch (e) { return e.name + ': ' + e.message; }
};
</script>
// run.js
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
const logs = [];
p.on('console', m => logs.push(m.type() + ': ' + m.text()));
await p.goto('http://127.0.0.1:8801/index.html');
const fr = await (await p.$('#f')).contentFrame();
await fr.waitForFunction(() => typeof window.trySend === 'function');
for (const t of ['http://127.0.0.1:8802', 'http://127.0.0.1:8801', '*', 'http://localhost:8801', '/', 'not-a-url']) {
const before = logs.length;
const r = await fr.evaluate(([l, t]) => window.trySend(l, t), [t, t]);
await p.waitForTimeout(300);
const got = await p.evaluate(l => window.got.some(g => g.data === l), t);
console.log(t, r, got ? '届いた' : '届かない', logs.slice(before).join(' | '));
}
await b.close();
})();
(cd a && php -S 127.0.0.1:8801 &) ; (cd b && php -S 127.0.0.1:8802 &)
node run.js
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/