← シリーズに戻る

iframe隔離を実装で読む — Safie の 160KB / 2.61MB 分離を分解する

Part 2 / 6 script 1行で配るJS SDKの設計 — 実在5製品を分解する

SDK の規模には大小あります。前記事で読んだ YouTube の loader は 993 バイトしかなく、ソース全体を通し読みできる小ささでした。今回扱う Safie は反対側の極端で、顧客サイトに刺さる loader が 160 KB、その loader が呼び出す iframe 本体が 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 body 合計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 を厳格に運用しているため、ここに大きなライブラリを持ち込む余地がありません。

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 の要件から逆算した最小許可です。

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

Safie の SYN/ACK + AES-GCM ハンドシェイクシーケンス図。親(顧客サイト)と子iframe(safie.link)の間で非対称鍵経由の AES セッション鍵交換と origin 検証を含む4ステップの流れ

main.js を grep すると、次のパターンが見えます。

postMessage("syn", { privateKey: a.privateKey, wrappedAesKey: c.wrappedAesKey })
postMessage("syn", null)
"ack"

3ステップです。

  1. SYN: 親が iframe に SYN メッセージを送る。中に非対称鍵の公開鍵と、AES セッション鍵を公開鍵で wrap したもの (wrappedAesKey) を入れる
  2. ACK: 子 iframe が wrap 済み AES 鍵を自分の秘密鍵で解いて、AES-GCM のセッション鍵として保持し、ACK を返す
  3. 以後の通信: 全 postMessage ペイロードを AES-GCM で暗号化

TCP のハンドシェイクを postMessage の上に再実装した形です。ここまで組む理由は、認証トークンが postMessage イベントで流れるためです。同一オリジン内でも window.opener 経由や兄弟 iframe から MessageEvent を傍受される可能性があるため、送信前に暗号化しておくと盗聴を構造的に防げます。

origin 検証の実装

main.js から origin 検証まわりの抜粋:

if (e.origin !== this._targetOrigin) return

シンプルですが、これが postMessage セキュリティの中核です。この一行を省くと、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項目です。

  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 ペイロードを暗号化するか
    同一オリジンでも MessageEvent は他 iframe から傍受可能。認証トークンや個人情報が乗るなら AES-GCM 暗号化を検討。

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

次: postMessage の中身を設計する

ここまでで iframe 隔離の「殻」の話をしました。次の spoke では、SYN/ACK の後で流れる実際のメソッド呼び出しをどう定義するかを扱います。JSON Schema と .d.ts の使い分け、エラー返却の慣習、非同期リクエストの相関 ID 設計、AES-GCM を本当に組む場面と組まない場面の判断基準です。

実測日は 2026-08-08。Safie のビルド ID、CSP nonce、AES-GCM 鍵は毎回動きますが、構造は動きません。