iframe 隔離と CSP nonce: 2.61MB を別オリジンへ
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.html | 1.2 KB | iframe 本体の入口 (CSP ヘッダー付き) |
lib/main-*.js | 999 KB | UI 本体 |
lib/styles-*.css | 469 KB | UI スタイル |
lib/chunk-*.js × 3 | 1.08 MB | 遅延読み込み分 |
lib/polyfills-*.js | 64 KB | Angular 系ポリフィル |
| iframe 本体 合計 | 2.61 MB |
「顧客サイトに 160 KB 刺さって、その中から 2.61 MB の別世界を呼び出す」という構造です。この非対称性が Safie の SDK 設計の中心にあります。

なぜ 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 を運ぶ代償に見合う、という判断です。

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 にするより配信が単純になります。

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 に情報を持ち出せない ことを意味します。カメラ映像の認証トークンが漏れる先を、ブラウザ側で構造的に塞いでいます。

3. worker-src blob: と media-src blob:
ライブ映像を Media Source Extensions で処理するために Web Worker と blob URL の実行を許可しています。カメラ映像 SDK の要件から逆算した最小許可です。
親子ハンドシェイクを実装で読む

親 (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ステップです。
- SYN (子 → 親): 子 iframe が RSA-OAEP の鍵ペアと AES-GCM の鍵を作り、AES 鍵を公開鍵で wrap します。送るのは wrap 済みの鍵 (
wrappedAesKey) と秘密鍵 (privateKey) で、宛先 origin は"*"です - ACK (親 → 子): 親は届いた秘密鍵で
wrappedAesKeyを解いて AES 鍵を持ち、ACK を AES-GCM で暗号化して返します - 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 型 SDK を作るときの決断は次の6項目です。
-
iframe URL は単一 + hash 分岐か、種類ごとに別 URL か
Safie は前者。キャッシュ共有と JS 側での分岐コストのトレードオフ。iframe が2種類程度なら別 URL のほうが読みやすいと考えます。 -
CSP を per-request nonce まで組むか、
unsafe-inlineで妥協するか
nonce は動的ヘッダー配信インフラが必要。SDK が扱うデータの機密性が判断材料になります。カメラ映像・決済ならやる、動画プレイヤーなら過剰。 -
connect-srcを自社ドメインに閉じるか、*を許すか
閉じるとサードパーティ計測ツール (Google Analytics 等) を iframe 内で使えなくなります。Safie は自社に閉じる側を選んでいます。 -
SYN/ACK ハンドシェイクを組むか、投げっぱなしでいいか
親子で状態を持つ SDK (認証セッション等) なら SYN/ACK が要ります。単発の再生指示だけなら不要。 -
postMessage ペイロードを暗号化するか
targetOrigin を指定して特定の window に送ったメッセージは、兄弟 iframe には届きません。読まれうるのは、"*"で送って意図しない window が受け取る場合と、受信側の window で動く別スクリプトが listener を足す場合です。暗号化するなら、鍵を同じpostMessageに乗せない設計にします。Safie のように秘密鍵ごと送ると、効くのは整合性確認までです。 -
origin検証の例外処理をどこまで組むか
null/file://を例外扱いすると法人検証環境で動きますが、抜け道になり得ます。SDK の利用範囲と相談して決めます。

ここまでは 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 鍵は毎回動きますが、構造は動きません。
この記事は役に立ちましたか?