プロンプトと適用シナリオ
ホストはhttps://app.example.comで動作し、https://pay.example.netからの決済iframeを埋め込みます。ウィジェットの準備が整った後、ホストはロケール、テーマ、および短期間有効な決済セッション参照を送信します。iframeはready、高さの変更、ユーザーによるキャンセル、決済フローの完了を報告します。ページには複数の同一オリジンiframeが含まれる場合があり、ロード中にウィジェットがリダイレクトされたり再作成されたりする可能性があります。
双方向のpostMessageプロトコルとフロントエンド実装を設計してください。宛先ウィンドウ、受信者の同一性、メッセージの形状、初期化のタイミング、重複と順序の入れ替わり、ナビゲーション、クリーンアップ、およびセキュリティテストを網羅してください。完了メッセージはUIの更新を促すだけであり、ホストサーバーは信頼できる決済システムと最終的な決済状態を確認する必要があります。
この質問は、シニアフロントエンド、Webプラットフォーム、フロントエンドアーキテクチャ、およびアプリケーションセキュリティの職種に適しています。そのコアスキルは、ブラウザの同一オリジンポリシー、ドキュメント間メッセージング、クライアントの信頼境界、および非同期プロトコル設計であるため、カテゴリはfrontendです。CORSはブラウザスクリプトがクロスオリジンのネットワークレスポンスを読み取れるかどうかを制御します。これは、postMessageの宛先制約やメッセージリスナーの検証を代替するものではありません。
面接官が評価するポイント
第一の評価ポイントは、候補者が送信側と受信側の両方を保護しているかどうかです。ターゲットウィンドウが別のオリジンにナビゲートした後にデータが配信されないよう、送信側は厳密なtargetOriginを提供します。受信側はすべてのメッセージでevent.originをチェックし、期待されるウィンドウ参照がある場合はevent.sourceをチェックします。片方のみの実装では、情報漏洩や偽造の経路が残ってしまいます。
第二の評価ポイントは、候補者がメッセージングを未知の入力を持つパブリックなエントリポイントとして扱っているかどうかです。オリジンチェックに合格しても、コードが実行されたオリジンが特定されるだけです。ペイロードの形状が証明されるわけではなく、信頼できるオリジン上のXSSや不具合のあるコードが危険なコマンドを送信するのを防ぐこともできません。優れた回答では、event.dataからunknownへと型を絞り込む前に、バージョン管理されたエンベロープ、許可されたメッセージタイプ、フィールドの制約、サイズ制限、および状態遷移を定義します。
第三の評価ポイントは、非同期タイミングの処理です。iframeがリスナーを登録する前に送信された初期化メッセージは、何のエラーもなく失われます。信頼性の高いフローでは、まず親のリスナーを登録し、iframeをロードし、子にreadyを通知させ、検証後にのみinitを送信します。メッセージID、チャネルID、タイムアウト、および冪等な状態遷移によって、再試行、重複、遅延メッセージを処理します。
最後に、面接官はサーバー側の信頼境界を確認します。検証済みiframeからのcompleteメッセージであっても、決済の証明にはなりません。フロントエンドは機密情報を含まない結果参照を使用してホストサーバーに問い合わせる必要があります。CSP、frame-src、frame-ancestors、および最小限のsandbox権限は攻撃対象面を減らしますが、メッセージレベルの同一性およびデータ検証を置き換えるものではありません。
最初に確認すべき明確化の質問
- 両方のオリジンは固定されていますか? 2つの固定オリジンであれば厳密な比較が可能です。ホストが顧客のカスタムドメインをサポートしている場合、決済ページはそのセッションで許可された親オリジンをサーバーから取得する必要があります。最初に出現した
event.originを無条件に信頼してはなりません。 - どのような機密データがチャネルを通過しますか? テーマと高さは低リスクです。決済クレデンシャル、個人データ、または再利用可能なベアラートークンをブロードキャスト型のインターフェースで転送してはなりません。権限を渡す必要がある場合は、短期間有効で、オーディエンスが制限され、失効可能で、最小特権の参照を使用してください。
- 決済ページを埋め込むことができるのは誰ですか? 決済ページは
frame-ancestorsを使用して埋め込み元を制限する必要があります。正当なテナントが多数存在する場合は、すべてのサイトを許可するのではなく、サーバー側で正確なポリシーを生成するか、制御された埋め込みエントリポイントを使用します。 - 1つのページに同一オリジンのiframeがいくつ存在できますか? 1つの場合、オリジンと期待される
contentWindowによってピアを特定できます。複数インスタンスの場合は、個別のウィンドウ参照とチャネルにバインドされたプロトコル状態が必要であり、オリジンのみに基づくブロードキャストハンドラでは不十分です。 - どのメッセージが機密性の高いアクションをトリガーしますか?
resizeはレイアウトに直接影響を与える可能性があります。complete、返金、注文の送信、アカウントの変更には、サーバーの認可と信頼できる状態の照会が必要です。メッセージはシグナルを運ぶだけであり、ビジネス上の権限を持つものではありません。 - 失敗後に再試行できるものは何ですか?
readyとinitは冪等に再試行できます。再送信されたメッセージによって決済処理が2回実行されてはなりません。メッセージID、終了状態、サーバーの冪等性キーを誰が管理するかを定義してください。
30秒の回答フレームワーク
「私はpostMessageを信頼境界を越える非同期APIとして扱います。子がreadyを送信する前に、親はリスナーを登録し、iframeのcontentWindowを保存します。すべてのメッセージに対して、親は厳密なオリジン、期待される送信元、実行時スキーマを検証し、厳密なtargetOriginを使用してinitで返信します。プロトコルにはバージョン、チャネルID、メッセージIDがあり、ステートマシンによってハンドシェイク前のメッセージ、重複、順序の入れ替わり、終了状態後の変更を拒否します。completeは、ホストがサーバーを介して信頼できる決済ステータスを照会する契機に過ぎません。悪意のあるオリジン、誤った同一オリジンiframe、不正なデータ、リプレイ、ナビゲーション競合をテストし、CSPと最小限のiframe権限で攻撃対象面を減らします。」
ステップバイステップの詳細解説
ステップ1:双方向の信頼境界をマッピングする
親が送信する前に、2つの確認事項があります。保持しているWindow参照が意図したiframeのものであるか、そして呼び出し実行時にターゲットドキュメントがまだ期待されるオリジンを維持しているかです。targetOriginは2つ目の問題に対処します。ターゲットが別の場所にナビゲートした場合、ブラウザはメッセージを破棄します。"*"を使用すると、その受信者制約が失われます。
受信側にも独立した2つの確認事項があります。どのオリジンがメッセージを送信したか、そしてそのオリジンのどのウィンドウが送信したかです。現在のウィンドウへの参照を取得したページは、どれでもメッセージの送信を試みることができます。同じ信頼できるオリジン上の別のiframeも、同じ名前のイベントを発火させる可能性があります。したがって、受信側は少なくとも以下をチェックします。
event.originが完全なスキーム、ホスト、ポートと完全に一致すること。event.sourceがiframeの保存されたcontentWindowと同じ参照であること。event.dataが現在のプロトコル状態によって許可されたメッセージ形状と一致すること。
オリジンの受け入れに、接尾辞の一致、正規表現の断片、またはindexOfを使用してはなりません。https://pay.example.net.attacker.testは緩い部分文字列チェックを通過してしまう可能性があります。また、event.originを許可リストに動的に追加してはなりません。攻撃者の最初のプローブが登録されてしまうためです。
ステップ2:小さく明示的なメッセージプロトコルを定義する
メッセージは任意のオブジェクトや文字列コマンドではなく、直和型(discriminated union)として表現します。コンパクトなプロトコルには以下を含めることができます。
| フィールド | 目的 | 検証 |
|---|---|---|
v | プロトコルバージョン | サポートされている整数バージョンのみを受け入れる |
type | メッセージタイプ | 固定のEnum値。動的な関数名を呼び出さない |
messageId | 重複排除と監査の関連付け | 空でなく、長さが制限され、現在のウィンドウ内で一意であること |
channelId | 1回の成功したハンドシェイクをバインド | readyの後に親によって作成され、以降のすべてのメッセージが一致する必要がある |
| ビジネスフィールド | そのメッセージに必要な最小限のデータ | 各タイプ、範囲、長さを検証する |
チャネルが存在する前には、readyにはchannelIdがありません。親は検証済みのオリジンと送信元を通じてそれを受信し、ランダムなchannelIdを生成してinitを送信します。以降のresize、cancel、completeメッセージはそのチャネルIDをエコーする必要があります。チャネルは、同じウィンドウ内の古いセッションや遅延メッセージを分離します。これは、オリジン、送信元、またはサーバーの認可を代替するものではありません。
受信側のパースはunknownから開始します。以下は親側の簡略化されたTypeScriptの例です。parseWidgetMessageは型アサーションではなく、厳格な実行時検証を表します。
const WIDGET_ORIGIN = "https://pay.example.net"
const frame = document.querySelector<HTMLIFrameElement>("#payment-widget")
if (!frame?.contentWindow) {
throw new Error("Payment iframe is unavailable")
}
const widgetWindow = frame.contentWindow
let channelId: string | null = null
const seenMessageIds = new Set<string>()
let state: "loading" | "active" | "completed" | "closed" = "loading"
window.addEventListener("message", onWidgetMessage)
frame.src = "https://pay.example.net/embed"
function onWidgetMessage(event: MessageEvent<unknown>) {
if (event.origin !== WIDGET_ORIGIN) return
if (event.source !== widgetWindow) return
const message = parseWidgetMessage(event.data)
if (!message || seenMessageIds.has(message.messageId)) return
seenMessageIds.add(message.messageId)
if (message.type === "ready") {
if (state !== "loading") return
channelId = crypto.randomUUID()
widgetWindow.postMessage(
{
v: 1,
type: "init",
messageId: crypto.randomUUID(),
channelId,
locale: "zh-CN",
theme: "system",
checkoutSessionRef: "short-lived-opaque-reference",
},
WIDGET_ORIGIN,
)
state = "active"
return
}
if (state !== "active" || channelId === null || message.channelId !== channelId) return
if (message.type === "resize") {
frame.style.height = `${Math.min(Math.max(message.height, 240), 900)}px`
}
if (message.type === "complete") {
state = "completed"
void refreshAuthoritativePaymentStatus(message.resultRef)
}
}実際のパーサーは、認識されない危険な型、過剰に長い文字列、非有限数、および許可されていない状態の組み合わせも拒否します。実行時チェックをスキップするためにas WidgetMessageを使用してはなりません。構造化複製はオブジェクトを転送できますが、そのオブジェクトがアプリケーションプロトコルを満たしていることを証明するものではありません。
子は逆方向にも同じルールを適用します。事前に設定された、またはサーバー側セッションによってバインドされたホストオリジンのみを受け入れ、event.source === window.parentを要求し、initスキーマを検証した後にのみチャネルIDを保存し、常に厳密なホストオリジンで応答します。相手が1つだけと想定されているという理由だけで、どちらのピアも検証を省略してはなりません。
ステップ3:ハンドシェイクとステートマシンでタイミングの曖昧さを排除する
HTML標準では、新しくナビゲートされた子ドキュメントがまだメッセージリスナーをインストールしていない可能性があると警告されています。その時点で送信された親メッセージは、子が準備完了になるまでキューで待機することはありません。親はiframeのURLを設定または確定する前にリスナーをインストールします。子はリスナーをインストールしてreadyを通知します。親はreadyがオリジン、送信元、スキーマのチェックを通過した後にのみ、機密性の高い初期化を送信します。
ステートマシンは許可されるアクションを明示します。
| 現在の状態 | 受け入れられるメッセージ | 遷移後の状態 |
|---|---|---|
loading | ready | active |
active | resize, cancel, complete | 同一、closed、またはcompleted |
completed | ビジネスメッセージなし(オプションの読み取り専用確認) | completedを維持 |
closed | なし | closedを維持 |
親はハンドシェイクにタイムアウトを設定できます。タイムアウト時には再試行可能なエラーを表示し、古いiframeを破棄します。iframeの再作成には、新しいチャネルID、空の重複排除セット、更新された期待ウィンドウ参照、および古いリスナーの削除が必要です。古いチャネルを再利用すると、以前のページからの遅延メッセージが新しいセッションに入り込んでしまいます。
ステップ4:重複メッセージ、重複ビジネスアクション、サーバーの事実を分離する
メッセージの配信とビジネスの実行には、2つの層の冪等性が必要です。フロントエンドはmessageIdを使用して重複したUIメッセージを無視し、ステートマシンを使用して終了状態後の変更を拒否します。リロード、ネットワーク再試行、複数のタブによってフロントエンドのセットがバイパスされる可能性があるため、重複請求を防ぐにはサーバー側で注文または決済試行の冪等性キーが依然として必要です。
completeは、信頼できるウィンドウがフローの完了を主張していることのみを意味します。親はresultRefを自身のサーバーに送信します。サーバーは現在のユーザー、注文の所有権、金額、および決済プロバイダーの信頼できる状態を検証した上で、表示可能な結果を返します。決済iframeのオリジン上のスクリプトがXSSによって制御されている場合でも、偽造されたcompleteによって注文が直接「支払い済み」になることはありません。
メッセージログには、プロトコルバージョン、タイプ、チャネルのハッシュ、結果、拒否理由のみを含めます。決済クレデンシャル、完全な個人データ、再利用可能なトークンは含めません。攻撃や長時間実行されているページによってメモリが無制限に消費されないよう、重複排除セットに上限を設けるか、チャネルとともに破棄します。
ステップ5:ナビゲーション、複数インスタンス、共有オリジンを処理する
event.originは、postMessageを呼び出した時点での送信元のオリジンです。送信ウィンドウが現在または将来にわたって同じオリジンを持つことを保証するものではありません。一度のハンドシェイク後にウィンドウを永続的に信頼するのではなく、すべてのメッセージを再検証してください。ターゲットiframeが攻撃者のオリジンにナビゲートした場合、厳密なtargetOriginによりその後の親メッセージは破棄され、親の受信側はオリジンが異なるため新しいメッセージを拒否します。
iframeが同じ信頼できるオリジン上の別のアプリケーションにナビゲートした場合、オリジンチェックは通過します。送信元、チャネルID、プロトコルバージョン、メッセージタイプ、およびサーバーの確認によって、古いセッション、誤ルーティング、ビジネスへの影響を制限することはできますが、そのオリジン上の悪意のあるコードを防ぐことはできません。ブラウザはオリジン全体を1つのセキュリティプリンシパルとして扱います。異なる信頼レベルのアプリケーションは異なるサブドメインを使用する必要があり、URLパスやメッセージフィールドによってそれらを分離することはできません。
1つのページに複数の決済iframeがある場合、要素ごとに個別のcontentWindow、状態、チャネル、ハンドラレコードを保持します。1つのグローバルリスナーがインスタンスにルーティングすることは可能ですが、まずevent.source参照をインスタンスにマッピングする必要があります。メッセージ内で提供されたインスタンスIDを盲目的に信頼することから始めてはなりません。
ステップ6:iframeの機能と埋め込み関係を制限する
ホストのCSPのframe-srcは、承認された決済オリジンのみを許可します。決済サイトのframe-ancestorsは、承認されたホストのみによる埋め込みを許可します。iframeのsandboxは、スクリプト、フォーム、特定のポップアップなど、決済フローが実際に必要とする機能のみを有効にします。追加されるすべての権限は、テスト可能な要件に対応している必要があります。
これらの制御は、誰が誰をロードできるか、および埋め込まれたドキュメントがどのブラウザ機能を持つかを規定します。これらはメッセージを認証するものではなく、"*"を安全にするものでもありません。どのサイトでも認証済みの決済ウィジェットを埋め込める場合、攻撃者は制御下のページにそれをロードしてアクティブにコマンドを送信できます。frame-ancestorsはその経路を直接狭めます。
ハンドシェイク後にピア間で高頻度の独立したストリームが必要な場合、認証された最初のpostMessageでMessagePortを転送できます。その後の通信では専用ポートが使用されるため、グローバルなmessageリスナー間の干渉が減少します。最初のポート配信には依然としてオリジン、送信元、スキーマのチェックが必要であり、ポートを保持していてもサーバー側のビジネス権限が付与されるわけではありません。
ステップ7:ネガティブテストで拒否パスを証明する
ポジティブテストは、正当なページが機能することのみを証明します。セキュリティ検証では、拒否されるべき入力を構築します。
| テスト | 期待される結果 |
|---|---|
攻撃者オリジンが適切にフォーマットされたcompleteを送信 | オリジンチェックで拒否され、決済照会は実行されない |
| 信頼できるオリジン上の別のiframeがメッセージを送信 | 送信元チェックで拒否される |
| 正しいオリジンと送信元が未知のタイプやサイズ超過フィールドを送信 | スキーマ検証で拒否される |
同じmessageIdがリプレイされる | 1回のみ処理される |
| iframe再作成後に古いチャネルが遅延メッセージを送信 | チャネルチェックで拒否される |
| 親が送信する前にiframeが別のオリジンにナビゲート | 厳密なtargetOriginによりメッセージが破棄される |
completeに結果が含まれていない、または他ユーザーの結果が含まれている | サーバーの認可と信頼できる照会で拒否される |
readyが遅延、重複、または到着しない | 冪等な処理またはタイムアウトにより再試行可能な失敗状態に遷移する |
コードレビューでは、"*"を指定したすべてのpostMessage呼び出し、すべてのmessageリスナー、あいまいなオリジン比較、未検証のevent.data、およびメッセージデータをinnerHTMLに書き込むパスも検索します。ブラウザ統合テストでは、リスナーの破棄、iframeの再作成、複数インスタンス、ルーティング変更をカバーします。
質の高い模範解答
「私はチャネルを送信境界と受信境界に分割します。親は保存されたiframeのcontentWindowにのみ送信し、厳密なtargetOriginとしてhttps://pay.example.netを使用します。すべての受信メッセージに対して、リスナーは実行時スキーマ検証の前に、完全なevent.originとその同じcontentWindow参照を比較します。ページに複数の同一オリジンiframeが含まれる可能性があるため、オリジン単体では不十分です。そのウィンドウがナビゲートしている可能性があるため、送信元単体でも不十分です。
私は、ready、init、resize、cancel、completeといった小さくバージョン管理されたメッセージセットを定義します。親はiframeをロードする前にリスナーを登録します。子はリスナーをインストールしてreadyを送信し、検証後にのみ親がチャネルIDを作成して初期化を送信します。以降のすべてのメッセージは同じチャネルIDと一意のメッセージIDを保持します。ステートマシンと重複排除セットにより、ハンドシェイク前のメッセージ、重複、順序の入れ替わり、完了後の状態後退を拒否します。ハンドシェイクのタイムアウト時は古いiframeを破棄し、再試行には新しいウィンドウ参照とチャネルを使用します。
完了メッセージによって注文が直接変更されることは決してありません。不透明な結果参照のみを伝達します。ホストサーバーはユーザーと注文を検証し、決済システムに信頼できるステータスを問い合わせます。したがって、信頼できる決済オリジンにバグやXSSが存在する場合でも、1つのメッセージで決済を偽造することはできません。
また、ホストのframe-src、決済ページのframe-ancestors、および最小限のサンドボックス権限でコンポーネントを制限します。テストでは、正当なフローに加えて、攻撃者オリジン、誤った同一オリジンiframe、古いチャネル、リプレイ、iframeのナビゲーション、readyのタイムアウトを網羅します。すべての拒否パスで機密性の高いビジネスアクションが実行されないようにします。」
よくある落とし穴
"*"での送信 → ターゲットウィンドウが攻撃者のページにナビゲートしている可能性があり、機密データを受信してしまう → 常に期待される厳密なtargetOriginを使用する。- 受信時に
event.data.typeのみをチェックする → ウィンドウ参照を持つ任意のページが指定のコマンドを偽造できる → データをパースする前に厳密なオリジンと期待される送信元を検証する。 - 部分文字列やドメイン接尾辞の断片でオリジンを受け入れる → 攻撃者のドメインに信頼できる文字列が含まれる可能性がある → ブラウザから提供される正規化された完全なオリジンを比較する。
- TypeScriptのアサーション後に処理を行う → 型は実行時には存在しないため、不正なデータがロジックに入り込む →
unknownから開始し、厳格なスキーマ、範囲、状態の検証を実行する。 - ページロード直後に
initを送信する → iframeのリスナーが登録されていない可能性があり、メッセージが消失する → 子にreadyを通知させ、それを検証してからタイムアウト付きで初期化する。 completeを決済の証明として扱う → フロントエンドのメッセージは信頼できるビジネス上の事実ではなく、信頼できるオリジンであっても侵害される可能性がある → ホストサーバーで認可を行い、信頼できる決済状態を照会する。- ハンドシェイク後にオリジンチェックを停止する → セッション中にウィンドウがナビゲートし、古い信頼がドキュメント間で持ち越される可能性がある → すべてのメッセージを検証し、再作成されたセッションを新しいチャネルで分離する。
- CORSやサンドボックスによってメッセージがすでに保護されていると思い込む → これらはネットワーク読み取りとドキュメントの機能を制限するものであり、メッセージの同一性やデータを保護するものではない → メッセージレベルのチェックを維持し、多層防御としてブラウザポリシーを使用する。
フォローアップの質問と回答
フォローアップ1:ウィジェットは数千の顧客カスタムドメインをサポートする必要があります。子はどのようにして親オリジンを知ることができますか?
最初のメッセージのevent.originを自動的に信頼してはなりません。ホストはまずサーバーとの間で埋め込みセッションを作成します。サーバーは顧客ドメインの所有権を検証し、許可された親オリジンを短期間有効なセッションにバインドします。決済ページはそのセッションから期待されるオリジンを取得し、送受信にはその値のみを使用します。セッションの期限切れ、ドメインの変更、またはウィンドウの再作成時には再認可が必要です。証明できないカスタムドメインには、機密性の高い埋め込み権限を与えてはなりません。
フォローアップ2:複数の同一オリジンiframeでメッセージングが必要です。channelIdだけでルーティングできますか?
ハンドラは、メッセージによって宣言されたチャネルを信頼することから始めることはできません。グローバルリスナーは、まずevent.sourceを使用して既知のWindow参照のレジストリからインスタンスを検索し、次にそのインスタンスのオリジン、状態、およびチャネルIDをチェックします。チャネルは古いセッションや順序の狂ったメッセージを拒否し、ウィンドウ参照はブラウジングコンテキストを識別します。これらは異なる目的を果たします。
フォローアップ3:iframeが同じ決済オリジン上のログインページにアクセスし、その後にチェックアウトページにアクセスします。ハンドシェイクはどのように機能しますか?
同一オリジン上のログインとチェックアウトはブラウザの同じセキュリティプリンシパルであり、親はevent.originからパスを知ることはできません。それらが同等に信頼されている場合、iframeのloadごとに親は古いチャネルを破棄してロード状態に戻ります。新しいドキュメントがセッションコントラクトに一致するreadyを送信した後にのみ再度ハンドシェイクを行い、前のドキュメントからの遅延メッセージを拒否します。ログインが初期化データを参照してはならない場合や信頼レベルが低い場合は、ログインを別のオリジンに移動し、チェックアウトに専用の埋め込みエントリポイントを提供してください。チャネルIDで同一オリジン分離の欠如を補うことはできません。
フォローアップ4:MessageChannelに切り替えた後もオリジンをチェックしますか?
ポートメッセージにはウィンドウメッセージのようなオリジンフィールドが含まれないため、セキュリティは誰が最初にポートを受信したかに依存します。ポートを転送する最初のpostMessageで、厳密なオリジン、送信元、およびハンドシェイク状態を検証する必要があります。ポートは現在のセッションにのみ属し、クローズ時に破棄されます。専用ポートにより誤ルーティングは減少しますが、初期認証、メッセージスキーマ、またはサーバーの認可を代替するものではありません。
フォローアップ5:iframeが自動的な高さ変更を制御します。ページが極端なサイズに引き伸ばされるのをどのように防ぎますか?
身元が検証されたresizeであっても、信頼できないビジネス入力です。プロトコルは有限の整数のみを受け入れ、親はそれらを製品で定義された最小および最大の高さにクランプし、更新頻度を制限(レート制限)します。範囲外または過剰なメッセージの拒否理由を記録します。コンテンツが本当に広いスペースを必要とする場合は、子に任意のCSS制御を与えるのではなく、内部スクロールを使用するか、新しい製品制限を定義してください。