題幹與適用場景
主站執行於 https://app.example.com,嵌入來自 https://pay.example.net 的付款 iframe。主站需要在元件就緒後傳送語言、主題和短效期的付款工作階段參照;iframe 回報 ready、高度變化、使用者取消和付款流程完成。頁面可能同時存在多個相同來源的 iframe,元件也可能在載入期間重新導向或被重新建立。
請設計雙向 postMessage 協定和前端實作,涵蓋傳送物件、接收者身分、訊息格式、初始化時序、重複與亂序、導覽、資源清理和安全測試。付款完成訊息只用來推動介面重新整理,最終付款狀態必須由主站伺服器向權威付款系統確認。
這道題適合資深前端、Web 平台、前端架構與應用安全職位。核心能力是瀏覽器同源政策、跨文件訊息、用戶端信任邊界和非同步協定設計,因此歸入 frontend。CORS 管理瀏覽器指令碼能否讀取跨來源網路回應;它不會取代 postMessage 的傳送目標和訊息監聽器驗證。
面試官考察點
第一個訊號是能否同時保護傳送端與接收端。傳送時要給出精確 targetOrigin,防止目標視窗導覽後由其他來源收到資料;接收時每一則訊息都要檢查 event.origin,並在已知視窗參照時檢查 event.source。只做其中一半仍會留下洩漏或偽造路徑。
第二個訊號是能否把訊息當作公開入口的未知輸入。通過 origin 驗證只說明程式碼執行於哪個來源;它不能證明資料結構正確,也不能阻止可信來源中的 XSS 或錯誤程式碼傳送危險指令。強回答會定義帶版本的封包、訊息類型、欄位限制、大小上限和狀態轉換,再把 event.data 從 unknown 收窄。
第三個訊號是能否處理非同步時序。父頁面在 iframe 註冊監聽器前傳送初始化會無聲遺失。可靠流程是父頁面先註冊監聽器,再載入 iframe,由子頁面傳送 ready,父頁面驗證後才傳送 init。訊息 ID、通道 ID、逾時和冪等狀態轉換負責處理重試、重複和遲到訊息。
最後看信任邊界是否落到伺服器。來自已驗證 iframe 的 complete 仍不是付款憑證;前端應拿不含敏感細節的結果參照查詢主站伺服器。CSP、frame-src、frame-ancestors 和最小化 sandbox 權限可以縮小攻擊面,但不會取代訊息層級的身分與資料驗證。
回答前需要釐清的問題
- 雙方來源是否固定? 固定的兩個 origin 可以精確比較。若主站允許客戶自訂網域,付款頁需要從伺服器取得該工作階段允許的父 origin,不能把任意
event.origin第一次出現就記為可信。 - 訊息包含哪些敏感資料? 主題和高度風險較低;付款憑證、個人資料或可重複使用的 bearer token 不應透過廣播式介面傳遞。確實需要傳遞能力時,應使用短效期、限定受眾、可撤銷且最小權限的參照。
- 誰可以嵌入付款頁? 付款頁應透過
frame-ancestors限制允許的嵌入者。若合法租戶很多,需要由伺服器產生準確政策或使用受控嵌入入口,不能允許所有網站。 - 同一頁面會有幾個相同來源的 iframe? 只有一個時,origin 加預期
contentWindow足以定位。多個實例必須分別保存視窗參照,並以通道 ID約束協定狀態,不能只按 origin 廣播處理。 - 哪些訊息會觸發敏感動作?
resize可以直接作用於版面;complete、退款、送出訂單或帳戶變更必須經過伺服器授權與權威狀態查詢。訊息只攜帶動作提示,不授予業務權限。 - 失敗後允許重試什麼?
ready和init可以冪等重試;付款送出不能因訊息重傳而重複執行。需要明確訊息 ID、終態和伺服器冪等鍵分別由誰維護。
30 秒回答框架
「我會把 postMessage 當成一個跨信任邊界的非同步 API。父頁先註冊監聽器並保存 iframe 的 contentWindow,子頁載入後傳送 ready;父頁逐則驗證精確 origin、預期 source 和訊息 schema,再用精確 targetOrigin 回覆 init。協定帶版本、通道 ID和訊息 ID,用狀態機拒絕未握手、重複、亂序和終態後的訊息。complete 只觸發主站向伺服器查詢權威付款狀態,不直接視為成功。最後用惡意 origin、同源錯誤 iframe、畸形訊息、重播和導覽競態做負向測試,並用 CSP 與最小 iframe 權限縮小暴露面。」
分步驟深入解答
第一步:先畫出兩個方向的信任邊界
父頁傳送前有兩個問題:取得的 Window 參照是否為目標 iframe,以及呼叫發生時目標文件的 origin 是否仍符合預期。targetOrigin 解決第二個問題;若目標已導覽到其他 origin,瀏覽器會丟棄訊息。使用 "*" 會放棄這項收件者限制。
父頁接收時也有兩個獨立問題:訊息來自哪個 origin,以及來自該 origin 下哪一個視窗。任何能取得目前視窗參照的頁面都可以嘗試傳送訊息;相同可信 origin 下的另一個 iframe 也可能傳送同名事件。因此接收器至少檢查:
event.origin與完整的通訊協定、主機和連接埠精確相等;event.source與目前 iframe 保存的contentWindow是同一個參照;event.data符合目前協定和目前狀態允許的訊息結構。
不要使用後綴包含、正規表示式片段或 indexOf 判斷 origin。https://pay.example.net.attacker.test 可以通過粗糙的字串包含檢查。也不要把 event.origin 動態回填到 allowlist;這會把攻擊者的第一次探測變成註冊流程。
第二步:定義小而明確的訊息協定
把所有訊息寫成可區分聯集,而不是任意物件或字串指令。一個精簡協定可以包含:
| 欄位 | 用途 | 驗證 |
|---|---|---|
v | 協定版本 | 只接受已支援的整數版本 |
type | 訊息類型 | 固定列舉,不執行動態函式名稱 |
messageId | 去重與稽核關聯 | 非空、長度受限、目前視窗內唯一 |
channelId | 綁定一次成功握手 | ready 後由父頁產生,後續必須精確符合 |
| 業務欄位 | 該訊息所需的最少資料 | 逐欄位類型、範圍和長度驗證 |
ready 在通道建立前沒有 channelId;父頁透過已驗證的 origin 與 source 接收它,產生隨機 channelId,再傳送 init。之後的 resize、cancel 和 complete 都必須回傳該通道 ID。通道 ID用於隔離同一視窗的舊工作階段和遲到訊息,不取代 origin、source 或伺服器授權。
接收器從 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 跳過執行階段檢查。結構化複製允許傳送物件,不代表物件符合業務協定。
子頁使用同一套規則處理反方向:它只接受預先設定或由伺服器工作階段綁定的主站 origin,要求 event.source === window.parent,驗證 init schema 後才保存通道 ID,並一律用精確的主站 origin 回傳訊息。父頁與子頁都不能因為自己「只接收一個夥伴」而省略檢查。
第三步:用握手和狀態機消除時序歧義
HTML 標準明確提示,新導覽到的子文件可能尚未安裝訊息監聽器,父頁此時傳送的訊息不會排隊等待它準備好。父頁應先安裝自己的監聽器,再設定或確認 iframe 位址;子頁安裝監聽器後傳送 ready。父頁只有在 ready 通過 origin、source 和 schema 驗證後才傳送敏感初始化資料。
狀態機把允許動作寫清楚:
| 目前狀態 | 接受的訊息 | 處理後狀態 |
|---|---|---|
loading | ready | active |
active | resize、cancel、complete | 保持、closed 或 completed |
completed | 無業務訊息;允許唯讀確認 | 保持 completed |
closed | 無 | 保持 closed |
父頁可以對握手設定逾時,逾時後顯示可重試錯誤並銷毀舊 iframe。重新建立時必須產生新通道 ID、清空去重集合、更新預期視窗參照,並移除舊監聽器。不能重複使用舊通道,否則舊頁面的遲到訊息可能落入新工作階段。
第四步:區分重複訊息、重複業務動作和伺服器事實
訊息傳遞與業務執行是兩層冪等性。前端用 messageId 忽略已處理的重複 UI 訊息,用狀態機拒絕終態後的變化。伺服器仍需用訂單或付款嘗試的冪等鍵防止重複扣款,因為頁面重新整理、網路重試和多個分頁都可能繞過前端集合。
complete 只表示可信視窗聲稱流程完成。父頁把 resultRef 傳送給自己的伺服器,伺服器驗證目前使用者、訂單歸屬、金額和付款提供者的權威狀態,再回傳可顯示結果。即使付款 iframe 所在 origin 被 XSS 控制,偽造 complete 也不能直接把訂單改成已付款。
訊息紀錄只保存協定版本、類型、通道雜湊、結果和拒絕原因,不記錄付款憑證、完整個人資料或可重複使用的 token。去重集合應設定數量上限或隨通道銷毀,避免攻擊或長時間頁面讓記憶體無限成長。
第五步:處理導覽、多個實例和共享 origin
event.origin 是傳送方呼叫 postMessage 當時的 origin,不保證傳送視窗現在或未來仍在該 origin。每一則訊息都必須重新驗證,不能在握手通過後永久信任視窗。目標 iframe 若導覽到攻擊者 origin,精確 targetOrigin 會阻止父頁繼續傳送,父頁接收器也會因 origin 不符而拒絕新訊息。
如果 iframe 導覽到同一可信 origin 的另一個應用程式,origin 檢查仍會通過。source、通道 ID、協定版本、訊息類型和伺服器確認可以限制舊工作階段、錯誤路由和業務影響,卻不能抵抗同一 origin 上的惡意程式碼;瀏覽器把整個 origin 視為一個安全主體。信任等級不同的應用必須使用不同子網域,不能只靠 URL 路徑或訊息欄位隔離。
同一頁面有多個付款 iframe 時,為每個元素保存自己的 contentWindow、狀態、通道和監聽處理。可以用一個總監聽器路由到實例,但路由鍵必須先來自 event.source 的參照對應,不能先信任訊息中的實例 ID。
第六步:限制 iframe 與嵌入關係的權限
主站 CSP 的 frame-src 只允許載入核准的付款 origin;付款站點的 frame-ancestors 只允許獲准主站嵌入。iframe 的 sandbox 只開啟付款流程真正需要的能力,例如指令碼、表單或特定彈出式視窗。每增加一項權限都應對應一個可驗證需求。
這些控制解決的是「誰能載入誰」和嵌入文件擁有哪些瀏覽器能力。它們不能驗證某一則訊息,也不能讓 "*" 變安全。付款元件若允許任意網站嵌入,攻擊者可能在自己的頁面載入已登入元件並主動傳送指令;frame-ancestors 可以直接收窄這條攻擊路徑。
若握手後需要高頻、獨立的雙向串流,可以透過已驗證的第一則 postMessage 轉移一個 MessagePort。這能把後續通訊移到專用連接埠,減少全域 message 監聽器之間的干擾;初始連接埠交付仍必須完成 origin、source 和 schema 驗證,持有連接埠也仍不等於擁有伺服器業務權限。
第七步:用負向測試證明拒絕路徑
正向測試只能證明合法頁面能運作。安全驗證要建構本應被拒絕的輸入:
| 測試 | 預期結果 |
|---|---|
攻擊者 origin 傳送合法外形的 complete | origin 驗證拒絕,不查詢付款狀態 |
| 同一可信 origin 的另一個 iframe 傳送訊息 | source 驗證拒絕 |
| 正確 origin 與 source 傳送未知類型或超大欄位 | schema 驗證拒絕 |
重播同一個 messageId | 只處理一次 |
| 舊通道在 iframe 重建後傳送遲到訊息 | channel 驗證拒絕 |
| iframe 在父頁傳送前導覽到其他 origin | 精確 targetOrigin 使訊息被丟棄 |
complete 攜帶不存在或他人的結果參照 | 伺服器授權與權威查詢拒絕 |
ready 延遲、重複或永遠不來 | 冪等處理或逾時進入可重試失敗狀態 |
程式碼審查同時搜尋所有 postMessage 呼叫中的 "*"、所有 message 監聽器、模糊 origin 比較、未檢查的 event.data,以及把訊息寫入 innerHTML 的路徑。執行瀏覽器整合測試時也要驗證監聽器卸載、iframe 重建、多個實例和路由切換。
高品質示範回答
「我先把通訊分成傳送和接收兩個邊界。父頁只向保存下來的 iframe contentWindow 傳送,並把 https://pay.example.net 作為精確 targetOrigin。監聽器對每一則訊息都比較完整 event.origin 和同一個 contentWindow 參照,再做執行階段 schema 驗證。只檢查 origin 不夠,因為頁面可能有多個同源 iframe;只檢查 source 也不夠,因為該視窗可能已經導覽。
我會定義版本化的少量訊息:ready、init、resize、cancel 和 complete。父頁先監聽再載入 iframe,子頁安裝監聽器後傳送 ready,父頁驗證通過才產生通道 ID並傳送初始化。後續訊息必須帶同一通道 ID與唯一訊息 ID;前端用狀態機和去重集合拒絕未握手、重複、亂序以及完成後的狀態回退。握手逾時就銷毀舊 iframe,重試時建立新視窗參照和新通道。
付款完成訊息不會直接改變訂單。它只攜帶不透明結果參照,讓主站伺服器驗證使用者和訂單,再向付款系統查詢權威狀態。這樣可信付款 origin 中的指令碼出錯甚至發生 XSS,也不能只靠一則訊息偽造付款。
我還會讓主站透過 frame-src 限制可載入的元件,讓付款頁透過 frame-ancestors 限制嵌入者,並給 iframe 最小 sandbox 權限。驗證時除了合法流程,也會從攻擊者 origin、錯誤同源 iframe、舊通道和重播訊息發起測試,模擬 iframe 導覽與 ready 逾時,並確認所有拒絕路徑都不會觸發敏感業務動作。」
常見錯誤
- 傳送時使用
"*"→ 目標視窗可能已導覽到攻擊者頁面,敏感資料仍會被投遞 → 一律使用預期的精確targetOrigin。 - 接收時只檢查
event.data.type→ 任意能取得視窗參照的頁面都能偽造同名指令 → 先驗證精確 origin 和預期 source,再解析資料。 - 使用網域包含或後綴片段判斷 origin → 攻擊者網域可以包含受信任字串 → 比較瀏覽器提供的完整正規化 origin。
- TypeScript 型別斷言後直接處理 → 型別在執行階段不存在,畸形值仍會進入邏輯 → 從
unknown開始做嚴格 schema、範圍和狀態驗證。 - 頁面載入後立即傳送
init→ iframe 監聽器可能尚未註冊,訊息無聲遺失 → 由子頁傳送ready,驗證後再初始化並設定逾時。 - 把
complete當成付款成功 → 前端訊息不是業務權威事實,可信 origin 也可能失陷 → 由主站伺服器授權並查詢權威付款狀態。 - 握手通過後不再檢查 origin → 視窗可以在工作階段中導覽,舊信任會跨文件延續 → 逐則驗證並用新通道隔離重建工作階段。
- 認為 CORS 或 sandbox 已保護訊息 → 它們分別約束網路讀取和文件能力,不驗證訊息傳送者與資料 → 保留訊息層級驗證,並把瀏覽器政策作為縱深防禦。
追問及應對
追問一:付款元件要支援數千個客戶自訂網域,子頁如何知道父 origin?
不要把第一則訊息的 event.origin 自動加入信任清單。主站先向伺服器建立嵌入工作階段,伺服器驗證客戶網域歸屬並把允許的父 origin 綁定到短效期工作階段。付款頁載入時依該工作階段取得預期 origin,傳送和接收都只使用這個值;工作階段過期、網域變更或視窗重建都重新授權。若無法可靠證明自訂網域歸屬,就不能給它敏感嵌入能力。
追問二:多個同源 iframe 都需要通訊,能否只靠 channelId 路由?
不能先信任訊息自己宣告的通道。全域監聽器先用 event.source 在已登記的 Window 參照對應中找到實例,再檢查該實例的 origin、狀態和通道 ID。通道 ID用於拒絕舊工作階段和亂序訊息;視窗參照負責定位瀏覽內容,兩者職責不同。
追問三:iframe 會先經過同一付款網域的登入頁,再導覽到結帳頁,握手怎麼辦?
同一 origin 的登入頁與結帳頁屬於同一個瀏覽器安全主體,父頁無法從 event.origin 得知具體路徑。若兩者同等可信,父頁在每次 iframe load 後廢棄舊通道並回到 loading,等待新文件傳送符合工作階段契約的 ready 後重新握手;這能拒絕舊文件的遲到訊息。若登入頁不應看到初始化資料或信任等級較低,就必須把它放到不同 origin,並為結帳頁建立獨立嵌入入口,不能指望通道 ID彌補同源隔離缺失。
追問四:使用 MessageChannel 後還需要檢查 origin 嗎?
連接埠訊息本身沒有與 window 訊息相同的 origin 欄位,所以安全性取決於連接埠最初交給了誰。交付連接埠的第一則 postMessage 必須驗證精確 origin、source 和握手狀態;連接埠只能在目前工作階段使用並在關閉時銷毀。專用連接埠能減少錯誤路由,但不能取代初始認證、訊息 schema 或伺服器授權。
追問五:業務要求 iframe 主動通知自動調整高度,如何防止頁面被撐到異常尺寸?
resize 通過身分驗證後仍是不可信業務輸入。協定只接受有限整數,父頁依版面允許的最小和最大高度夾取,並限制更新頻率;超限或高頻訊息記錄拒絕原因。若內容確實需要更高尺寸,改為內部捲動或由產品定義新的上限,不能讓子頁直接控制任意 CSS。