postMessage の契約設計 — Vimeo・YouTube・Safie で3世代を比較する
前記事で Safie の iframe 隔離を分解しました。iframe 内には自社の CSP と自社の JS が動いていて、親子は postMessage で会話しています。今回はその「会話の中身」を設計する話です。5製品のうち Vimeo・YouTube・Safie の3つが iframe 型なので、この3つのプロトコルを並べて読みます。
プロトコル早見表
2026-08-08 時点で各バンドルから抽出した実装です。
| 製品 | メッセージ形式 | 型検証 | Origin検証 | ハンドシェイク | 暗号化 |
|---|---|---|---|---|---|
| Vimeo | {method, value, event, data} | 実装内 === "ready" 等 | first-message trust (* → 実 origin) | なし (直接 method 呼び出し) | なし |
| YouTube | {event, id, channel, data} | 内部 switch 分岐 | .origin === k.O 等値比較 | "listening" → "initialDelivery" | なし |
| Safie | {type, data, guid} + wrapped payload | JSON Schema (safie.link/schemas/...) | origin !== this._targetOrigin return | "syn" → "ack" + 鍵交換 | AES-GCM |
3世代あります。Vimeo が最小構成、YouTube が状態同期を加えたもの、Safie がハンドシェイク + 暗号化まで含む最重量。SDK が扱うデータの機密性と、親子で共有する状態の量で必要な世代が決まります。
順に見ていきます。
Vimeo: 最小構成
Vimeo のバンドル(24 KB / gzip 8 KB)から postMessage まわりを抜き出すと、驚くほど短いです。
// 抜粋 (整形済み)
postMessage(e, t.origin)
// 受信側
event.data.event === "ready"
event.data.event === "spacechange"
event.data.event === "drminitfailed"
{method, value, event, data} の JSON を投げ合うだけの構造で、method 呼び出しがそのままメッセージになります。ハンドシェイクは無く、iframe が読み込まれたら親から即座に method を投げていい。もし iframe 側の準備がまだなら、iframe 側が event: "ready" を送るまで親側で待つ実装になっています。
Origin 検証は少し変わっています。
"*" === this.origin && (this.origin = event.origin)
「初回メッセージで受け取った origin を正解とみなす」パターンです。これは iframe の実際のオリジンが構築時点でわからない場合(sandbox 属性、動的サブドメイン等)への実用的な妥協で、first-message trust と呼ばれます。理論的には初回に攻撃側 iframe が先に喋ると乗っ取られますが、iframe の onload 直後に親から ping を投げて子から応答が来るまで待つ実装なので、実質的にはあまり露出しません。それでも AES-GCM 級の防御ではありません。
YouTube: 状態同期を足したもの
YouTube widgetapi (27 KB) は Vimeo より一世代新しいプロトコルです。
// 語彙
"listening" // 子から親へ「準備できた」
"initialDelivery" // 親から子へ「初期状態はこれ」
"command" // 親から子へメソッド呼び出し
"event" // 子から親へ状態変化通知
"listening" が実質的なハンドシェイクの片側で、iframe 側の JS 初期化が終わったタイミングで親に送られます。親はそれを見てから "initialDelivery" で動画 ID・自動再生・字幕設定を子に流し、以後 "command" で再生・停止を、"event" で状態変化を受け取ります。
Origin 検証は等値比較で厳密です。
event.origin === k.O
k.O は iframe を作った時点で覚えておいた自社オリジン(通常 https://www.youtube.com)で、リクエスト毎に検証されます。Vimeo の first-message trust と比べて、コードは短いのに硬い設計です。ハンドシェイクが片側だけなのは、YouTube が扱うデータ(動画メタデータ、再生位置)がそもそも公開情報だから、盗聴対策より状態同期の正確さが優先だからと読めます。
Safie: 最重量、機密データ前提
Safie は前記事で見たとおりです。復習:
postMessage("syn", { privateKey: a.privateKey, wrappedAesKey: c.wrappedAesKey })
// 受信側
event.data.type === "ack"
event.data.type === "connected"
// origin検証
if (event.origin !== this._targetOrigin) return
// 以後のペイロードは AES-GCM で暗号化
TCP 3-way handshake の postMessage 版に、非対称鍵経由の AES-GCM 鍵交換を接ぎ木した形。親子間を流れる認証トークン・監視カメラ ID を、同一オリジン内の別 iframe に傍受されても解読できないようにしています。
Safie がここまで組む理由は、扱うデータが「録画映像へのアクセス権を含む」からです。動画プレイヤーの currentTime が漏れても実害は小さい。カメラ映像 SDK の認証トークンが漏れると、契約カメラを他人がライブ視聴できる状態が発生します。プロトコル世代の選択は、SDK が扱うデータの経済的・法的価値で決まります。
メソッド定義を JSON Schema で公開する
Safie のバンドルには safie.link/schemas/invoke-method/ の URL が並んでいます。
safie.link/schemas/invoke-method/auth/set-token
safie.link/schemas/invoke-method/devices/query-devices
safie.link/schemas/invoke-method/streaming/start-ptz
これらは JSON Schema の URI で、postMessage で送るメッセージの構造を機械可読で定義しています。ランタイムに検証されるので、想定外の型が来たら即座に弾けます。TypeScript の .d.ts はエディタ支援に効きますが、親子で別ドメインの JS が動く SDK では両側で同じ型定義を確実に共有するのが難しく、ランタイム検証の JSON Schema が保険として効きます。
Vimeo や YouTube はここまでやっていません。代わりに実装内の event === "ready" のような文字列判定と、内部 TypeScript 型で通しています。5製品を並べると、機密度が上がるほどランタイム検証まで持ち込む傾向が見えます。
自作するときの決断ポイント
3世代のどこに位置を取るかは、以下で決められます。
-
親子で状態を共有するか
共有しない (Vimeo 型: method 呼び出しと event 通知だけ) → ハンドシェイク不要。
共有する (YouTube 型: 初期状態を親から流す) →"listening"相当の準備完了通知が要ります。 -
ペイロードの機密性はどの水準か
公開情報のみ → 暗号化不要。
同一オリジン内の他 iframe に見られたら困る → AES-GCM 鍵交換を組む。 -
メッセージ構造を機械可読で公開するか
.d.ts配布で十分な閉じたクライアント → TypeScript 型のみ。
サードパーティが独自クライアントを書く可能性がある → JSON Schema をランタイム検証込みで。 -
Origin 検証の厳密性
iframe オリジンが構築時に確定 → 等値比較 (YouTube 型)。
sandbox やサブドメイン切替が絡む → first-message trust (Vimeo 型) を選ぶ場合あり。ただしトレードオフあり。
次: 版と cache-control
プロトコルを固定したら、次はバージョン戦略です。5製品で cache-control ヘッダーと URL 構造をそろえて並べると、iframe 型と DOM 直挿し型で戦略が真っ二つに割れます。SDK 側で版を切らせる YouTube・Video.js・Mux Player と、切らせない Safie・Vimeo。この分岐の理由を次で扱います。
実測日は 2026-08-08。プロトコルの語彙とハンドシェイク形状は年単位で安定します。ペイロード仕様は動きます。
この記事は役に立ちましたか?