← シリーズに戻る

iframe 隔離と CSP nonce: 2.61MB を別オリジンへ

Part 2 / 6 JavaScript SDK 設計: 実在5製品のバンドル分解

SDK の規模には大小あります。YouTube iframe API: 993バイトを1行ずつ読む で読んだ YouTube の loader は 993 バイトしかなく、ソース全体を通し読みできる小ささでした。今回扱う Safie は反対側の極端で、顧客サイトに刺さる loader が 160 KB、その loader が呼び出す iframe 本体 (loader が読み込む実際のコード) が 2.61 MB あります。同じ「script 1行で埋め込む SDK」でも、規模と分業のさせ方がまったく違います。

なぜこの非対称な構造になっているのか、iframe 内で何を守っているのか、親子でどう会話しているのか。実装を見れば全部説明できます。

実測サイズ

2026-08-08 時点の HTTP ボディサイズです。

資源Raw役割
safie.link/sdk/js/api/v1/latest/ (loader)160 KB (gzip 37 KB)顧客サイトに刺さる <script>
lib/iframe.html1.2 KBiframe 本体の入口 (CSP ヘッダー付き)
lib/main-*.js999 KBUI 本体
lib/styles-*.css469 KBUI スタイル
lib/chunk-*.js × 31.08 MB遅延読み込み分
lib/polyfills-*.js64 KBAngular 系ポリフィル
iframe 本体 合計2.61 MB

「顧客サイトに 160 KB 刺さって、その中から 2.61 MB の別世界を呼び出す」という構造です。この非対称性が Safie の SDK 設計の中心にあります。

Safie の SDK のサイズを長さに比例した棒で比べた図。顧客サイトに刺さる loader (safie.link/sdk/js/api/v1/latest/) は 160 KB (gzip 37 KB)。自社オリジンの iframe 本体は合計 2.61 MB で、内訳は main (UI 本体) 999 KB、chunk ×3 (遅延読み込み) 1.08 MB、styles 469 KB、polyfills 64 KB。入口の iframe.html は 1.2 KB (CSP ヘッダー付き) で棒には見えない細さ。本体は loader の約16倍で、UI フレームワーク一式を持ち込んでも顧客の React や CSS とぶつからない。2026-08-08 に実測した HTTP ボディのサイズ

なぜ 2.61 MB が「顧客サイトの外」に居るか

カメラ映像 SDK は普通の埋め込みプレイヤーより仕事が多いです。

  • ライブ映像の WebSocket 接続 (wss://*.safie.link)
  • 認証トークンの保持と自動更新
  • タイムライン UI (録画の巻き戻し・シーク)
  • PTZ 制御 (Pan/Tilt/Zoom)
  • 静止画キャプチャ、サムネイル、位置情報 API

これを DOM 直挿しの Web Component として顧客サイトに置くと、顧客の React バージョンや CSS スタイル、Content-Security-Policy、Cookie ドメインすべてと衝突します。カメラ映像 SDK を導入する法人顧客は自社サイト側の CSP を厳しく運用していることが多く、そこに大きなライブラリを持ち込む余地は小さいと私は見ています (顧客側の CSP を調べたわけではありません)。

Safie は「顧客に見せる <script> は 160 KB のプロキシだけにして、本体は自社オリジンの iframe に閉じ込める」を選びました。iframe 内は自社の CSP、自社の Cookie、自社の UI フレームワークで自由に組めます。この自由度が 2.61 MB を運ぶ代償に見合う、という判断です。

2.61 MB を顧客の DOM に直接置く場合と自社オリジンの iframe に置く場合を、4つの項目で比べた表の図。題は「2.61 MB を顧客の DOM に置くと、4か所でぶつかる」。顧客サイトの DOM に直接置く (Web Component として埋め込んだ場合) と、UI フレームワークは顧客の React の版とぶつかり、CSS は顧客のスタイルとぶつかり、CSP は顧客の CSP の下で動いて大きなライブラリを持ち込む余地は小さく (私の見立て)、Cookie は顧客のドメインの Cookie とぶつかる。自社オリジンの iframe に置く (Safie が選んだ形) と、自社の UI フレームワーク一式をそのまま持ち込め、自社の UI で自由に組め、CSP は自社の CSP (iframe.html のヘッダー、per-request nonce と connect-src の制限)、Cookie は自社 (safie.link) の Cookie。顧客のページに刺さるのは 160 KB の loader だけで、その代わり 2.61 MB を iframe の中で運ぶ。サイズは 2026-08-08 の実測

4つの iframe

loader を読むと、iframe を作る箇所が4つあります。全部同じ iframe.html を指しますが、hash fragment で役割を分けています。

// 抜粋 (整形済み)
iframe.src = `${Te.iFrameURL}#safie-sdk-proxy`
iframe.src = `${Te.iFrameURL}#safie-sdk-streaming-player`
iframe.src = `${Te.iFrameURL}#safie-sdk-timeline-context`
iframe.src = `${Te.iFrameURL}#safie-sdk-timeline`

#safie-sdk-proxy は不可視の RPC 中継用 iframe で、認証・デバイス問い合わせなどのメソッド呼び出しは全部ここを通ります。残り3つは顧客ページに実際に描画される UI コンポーネント (ライブ再生・タイムライン) 用です。

hash fragment を使う理由は、同じ URL を使い回してブラウザキャッシュを効かせつつ、iframe 内 JS 側で location.hash を見て「自分は proxy なのか streaming-player なのか」を分岐させるためです。iframe を種類ごとに別 URL にするより配信が単純になります。

Safie の loader が同じ lib/iframe.html (CSP 付きの入口、1.2 KB) を4回開き、hash で役割を分ける図。#safie-sdk-proxy は見えない iframe で、メソッド呼び出しの中継 (認証・デバイス問い合わせはここを通る)。#safie-sdk-streaming-player は UI でライブ再生。#safie-sdk-timeline は UI でタイムライン。#safie-sdk-timeline-context は UI でタイムラインから開く枠 (ふだんは隠してある)。下に「URL が1つなので、ブラウザキャッシュを4つの iframe で共有できる」「中の JS は location.hash を見て、自分が proxy か player かを決める」

iframe 内の CSP を実測する

curl -I で iframe.html のヘッダーを取ります。

content-security-policy:
  default-src 'self';
  script-src 'self' 'nonce-2b57f8c8007890cd4abf6a1c4079f555';
  style-src  'self' 'nonce-2b57f8c8007890cd4abf6a1c4079f555';
  img-src    'self' blob: data:;
  media-src  'self' blob:;
  connect-src 'self' https://*.safie.link wss://*.safie.link blob:;
  worker-src  'self' blob:;
  font-src   'self' https://safie.link https://fonts.gstatic.com;

読みどころは3つあります。

1. script-src / style-src に per-request nonce

nonce-2b57f8c8... はリクエストごとに変わります。iframe.html を取り直すと違う nonce が返ってきます。iframe 内 HTML の <script nonce="..."> と一致した script だけが動く仕組みで、XSS で <script>alert(1)</script> を差し込まれても nonce が合わず実行されません。この運用は静的ホスティングでは組みづらく、動的にヘッダーを付けるサーバーが要ります。Safie 側は iframe.html だけそのサーバーから配って、後続の JS/CSS は nonce 縛りをつけて配信 CDN から配る、という分業を回しているはずです。

2. connect-src を自社ドメインに閉じる

connect-src は fetch/XHR/WebSocket の飛び先を制限します。ここに https://*.safie.link wss://*.safie.link blob: しか無いということは、iframe 内の JS が万一乗っ取られても外部 API に情報を持ち出せない ことを意味します。カメラ映像の認証トークンが漏れる先を、ブラウザ側で構造的に塞いでいます。

Safie の iframe.html の CSP (2026-08-08 に curl -I で取得) が止めるものを2列で示した図。左はどの script を実行するか (script-src / style-src が 'self' 'nonce-2b57f8c8…'、nonce は iframe.html を取るたびに変わる)。nonce が一致する script (iframe.html に Safie が書いたもの) は実行、XSS で差し込まれた script (nonce を知らない) は止める。毎回違うヘッダーを返すので静的ホスティングだけでは組めない。右はどこへ通信できるか (connect-src が 'self' https://*.safie.link wss://*.safie.link blob:)。https / wss の *.safie.link (ライブ映像の WebSocket など) は通し、それ以外の外部ドメインは iframe 内の JS が送ろうとしても止める。乗っ取られても認証トークンの漏れ先をブラウザの側で塞いでいる。ほかに worker-src と media-src に blob: を許可 (Media Source Extensions でライブ映像を扱うため)

3. worker-src blob: と media-src blob:

ライブ映像を Media Source Extensions で処理するために Web Worker と blob URL の実行を許可しています。カメラ映像 SDK の要件から逆算した最小許可です。

親子ハンドシェイクを実装で読む

Safie の SYN/ACK + AES-GCM ハンドシェイクのシーケンス図。子 iframe(safie.link)が鍵を作り、秘密鍵と wrap 済みの AES 鍵を SYN で親(顧客サイト)へ送る。親が AES 鍵を取り出して暗号化した ACK を返し、子が connected を返す3ステップの流れ。以後は親から子への命令だけが暗号化される

親 (loader) と子 (iframe 本体) は同じ MessageSender というクラスを持っていて、鍵を作るのは子の側です。2026-09-30 に loader と iframe 本体の main-*.js を取り直し、該当箇所を読みました (整形済みの抜粋)。

// 子 (iframe 本体): startCryptoHandshake()
const pair = await crypto.subtle.generateKey(
  { name: "RSA-OAEP", modulusLength: 2048, /* ... */ }, false, ["wrapKey", "unwrapKey"])
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"])
const wrappedAesKey = await crypto.subtle.wrapKey("raw", aesKey, pair.publicKey, { name: "RSA-OAEP" })
this._targetOrigin = "*"
this.postMessage("syn", { privateKey: pair.privateKey, wrappedAesKey })

// 親 (loader): SYN を受け取ったら
this._cryptoKey = await crypto.subtle.unwrapKey("raw", data.wrappedAesKey, data.privateKey, /* ... */)
this.postMessage("ack", { id, name })

3ステップです。

  1. SYN (子 → 親): 子 iframe が RSA-OAEP の鍵ペアと AES-GCM の鍵を作り、AES 鍵を公開鍵で wrap します。送るのは wrap 済みの鍵 (wrappedAesKey) と秘密鍵 (privateKey) で、宛先 origin は "*" です
  2. ACK (親 → 子): 親は届いた秘密鍵で wrappedAesKey を解いて AES 鍵を持ち、ACK を AES-GCM で暗号化して返します
  3. connected (子 → 親): 子は ACK を復号できたら、送り主の origin を以後の宛先にして "connected" を返します

ここから親 → 子の命令が AES-GCM で暗号化されます。子 → 親の結果と通知は平文のままです。

TCP の 3-way handshake を postMessage の上に再実装した形ですが、鍵の渡し方は変わっています。秘密鍵と wrap 済みの鍵が同じメッセージに乗るので、SYN を受け取れる相手は誰でも AES 鍵を取り出せます。privateKey はエクスポート不可の CryptoKey として作られていますが、postMessage の構造化複製ではそのまま相手に渡ります。傍受への防御というより、鍵を持たない相手が作ったメッセージを子が復号に失敗して捨てる整合性確認と、トークンを平文で流さない難読化に近い仕組みです。秘密鍵まで送る理由は、実装からは読み取れませんでした。

origin 検証の実装

MessageSender の受信側から origin 検証まわりの抜粋:

if (this._targetOrigin !== "*" && e.origin !== this._targetOrigin) return

シンプルですが、これが postMessage セキュリティの中核です。子の側は ACK が届くまで _targetOrigin が "*" なので、この比較が効くのは ACK の後からです。それまでは、event.source が親の window かどうかでメッセージを振り分けています。この一行を省くと、iframe 隔離の意味が消えます。攻撃側は任意サイトから iframe.contentWindow.postMessage(payload, "*") で命令を送れるためです。

Safie が慎重なのは、file:// と null オリジンを個別扱いしている点です。

e.origin === "null" || e.origin === "file://" ? "*" : e.origin

ローカルファイル環境や sandbox 属性付き iframe から呼ばれた場合の null オリジンを排除せず、送り返しの targetOrigin を * に落として動かす例外処理です。運用側の要求 (法人顧客の検証環境が file:// から立ち上がる等) と、安全側の切り分けを両立させています。

Safie の子 iframe が宛先と照合に使う _targetOrigin の移り変わりを示した図。題は「ACK の送り主が null か file:// なら、origin の照合は外れたまま」。ACK が届くまでは _targetOrigin = "*" で origin は照合せず、送り主の window (event.source) が親かどうかで振り分ける。ACK が届いたとき、送り主がふつうの origin (顧客サイトの https://…) なら _targetOrigin をその origin にし、以後は別の origin からのメッセージを捨てる。送り主が "null" か "file://" (sandbox 付き iframe、ローカルファイルの検証環境) なら _targetOrigin は "*" のままで、以後も origin を照合しない。受信の比較は if (this._targetOrigin !== "*" && e.origin !== this._targetOrigin) return、ACK のときは e.origin === "null" || e.origin === "file://" ? "*" : e.origin。結論は「検証環境で動かすための例外が、そのまま照合の抜け道にもなる」。safie.link/sdk/js/api/v1/latest/ の MessageSender (2026-10-01 取得)。鍵交換の後は、鍵を持たない相手のメッセージは復号に失敗して捨てられる

自作するときのチェックリスト

上を Safie 実装から抽出すると、iframe 型 SDK を作るときの決断は次の6項目です。

  1. iframe URL は単一 + hash 分岐か、種類ごとに別 URL か
    Safie は前者。キャッシュ共有と JS 側での分岐コストのトレードオフ。iframe が2種類程度なら別 URL のほうが読みやすいと考えます。

  2. CSP を per-request nonce まで組むか、unsafe-inline で妥協するか
    nonce は動的ヘッダー配信インフラが必要。SDK が扱うデータの機密性が判断材料になります。カメラ映像・決済ならやる、動画プレイヤーなら過剰。

  3. connect-src を自社ドメインに閉じるか、* を許すか
    閉じるとサードパーティ計測ツール (Google Analytics 等) を iframe 内で使えなくなります。Safie は自社に閉じる側を選んでいます。

  4. SYN/ACK ハンドシェイクを組むか、投げっぱなしでいいか
    親子で状態を持つ SDK (認証セッション等) なら SYN/ACK が要ります。単発の再生指示だけなら不要。

  5. postMessage ペイロードを暗号化するか
    targetOrigin を指定して特定の window に送ったメッセージは、兄弟 iframe には届きません。読まれうるのは、"*" で送って意図しない window が受け取る場合と、受信側の window で動く別スクリプトが listener を足す場合です。暗号化するなら、鍵を同じ postMessage に乗せない設計にします。Safie のように秘密鍵ごと送ると、効くのは整合性確認までです。

  6. origin 検証の例外処理をどこまで組むか
    null / file:// を例外扱いすると法人検証環境で動きますが、抜け道になり得ます。SDK の利用範囲と相談して決めます。

宛先の origin を指定して送った postMessage を誰が読めるかを4段で示した図。題は「宛先を指定した postMessage を、読めるのは誰か」。送り手が target.postMessage(msg, "https://宛先の origin") を呼ぶ。兄弟の iframe・別のタブには届かない (message イベントは postMessage を呼ばれた宛先の window にだけ起きる)。宛先の window が別の origin に替わっていたときも届かない (配送の時点で origin が一致しなければ捨てられる)。宛先の window で動く別のスクリプト (乗っ取られた依存ライブラリなど) は読める (listener を足せば全部見える)。宛先を "*" で送ったときの意図しない window も読める (origin を照合しないので、宛先の中身が何に替わっていても届く)。暗号化するなら鍵を同じ postMessage に乗せない。Safie の SYN は秘密鍵と wrap 済みの鍵を一緒に送るので、受け取れる相手は誰でも解ける。根拠は HTML Standard の window.postMessage (targetOrigin の照合)

ここまでは iframe を使う前提で並べましたが、置き先が自分の管理下にあるなら、隔離ごと捨てる手もあります。配信レイヤで HTML を書き換えて script を1行足すほうが軽く、管理する単位も増えません。iframe で嵌める案を検索評価の理由で落とした判断はプロダクトを出すたび支援導線が勝手に付くに書きました。他人のサイトに置く SDK ではこの逃げ道が無いので、上の6項目を通ることになります。

次: postMessage の中身を設計する

ここまでで iframe 隔離の「殻」の話をしました。次の章では、殻の中を流れるメッセージのほうを扱います。Vimeo・YouTube・Safie の postMessage を3つの世代として並べ、ハンドシェイクの形と origin 検証の厳しさを比べたうえで、メッセージの型を JSON Schema と .d.ts のどちらで書くか、AES-GCM を本当に組む場面と組まない場面の判断基準までを扱います。

実測日は 2026-08-08。ハンドシェイクの向きと鍵の渡し方は 2026-09-30 に loader と iframe 本体を取り直して確認しました。Safie のビルド ID、CSP nonce、AES-GCM 鍵は毎回動きますが、構造は動きません。