你讓 AI 做了一個 HTML 報表,想把它嵌進自己的網站預覽。外面加上 iframe,看起來像替作品裝了一個玻璃箱;但如果裡面的人能遞紙條,而宿主看到紙條就照做,箱子的邊界仍可能被應用程式自己打開。postMessage 安全,就是替這個收件口訂規則。
這篇寫給第一次碰跨視窗通訊、願意複製程式的新手。你會先用白話理解三道檢查,再用兩個 iframe 與全是假資料的頁面,驗證誰能改宿主狀態;最後把十個案例接成瀏覽器回歸測試。操作需要已有 Node.js 22.12.0 以上、npm 與 Chrome,不需模型 API 或登入帳戶。
背景來自 Parameter 於 2026 年 10 月 28 日公開的研究;研究者記載 OpenAI 已於 10 月 26 日修正。本文不重建該服務的攻擊鏈,而是把接收端信任邊界拆成自製本機練習。2026 年 11 月 3 日,AlphaLab 以 HeadlessChrome 153.0.8010.53 執行下方十個固定案例,保留瀏覽器版本、JSON 收據與截圖。
先說結論:可接受的訊息=指定視窗+允許來源+有限契約
可接受的訊息=指定視窗+允許來源+有限契約。把它當成櫃台收件:先確認是不是這次約好的窗口,再查寄件地址,最後看申請內容是否合規。地址正確,不能代表所有同地址的人都有權交同一份申請。
- 發送視窗看
source:是不是指定的 iframe? origin:訊息送出時的協定、主機與埠,是否完整吻合允許值?data:訊息型別、版本、欄位與數值範圍,是否符合這一個操作?
三道檢查後,也只開放一項很小的能力:本例允許指定元件回報 80 至 400 的整數高度。它不會收到登入資訊,不會要求宿主執行程式,也不會要求宿主插入 HTML。

一、postMessage 安全原理:玻璃箱與收件口是兩件事
iframe 是頁面裡的另一個瀏覽環境;同源政策限制跨來源頁面直接讀取彼此的內容。postMessage 則提供受瀏覽器管理的跨視窗送訊管道。依 HTML 標準的安全章節,接收端仍要檢查來源與資料格式;通訊成功和操作獲准,是兩個判斷。
Origin(來源)可以想成三格地址:協定+主機+埠。所以 http://127.0.0.1:8765 與 http://127.0.0.1:8766 是不同來源;路徑 /sender.html 則不屬於 origin。localhost 與 127.0.0.1 也別混用,本文全部固定後者。
發送時的 targetOrigin 指定「只能交給哪個來源的收件人」;事件的 origin 則告訴收件人「這封信送出時來自哪裡」。兩者方向不同。MDN 的 postMessage 文件另外提醒,視窗之後可能導向別處,因此還要核對 source,並避免把來源字串當成永久身分。
放在父頁執行的第三方元件,也在父頁的權限範圍裡工作。如果它另外安裝一個寬鬆的訊息監聽器,你自己寫得嚴格的監聽器不會替它攔信。要盤點的是每一個接收器,以及它後面能碰到的狀態和 HTML 寫入點。這與 json-render 的元件白名單互補:一個管可接受的 UI 規格,一個管瀏覽器訊息能觸發什麼。
二、先搭兩個 iframe:這是測試夾具,不是正式預覽拓樸
以下兩個子頁都是我們自己寫的固定測試頁。第一個叫 widget,代表獲准回報尺寸的元件;第二個叫 preview,代表不應操作這份契約的另一個 frame。刻意讓它們先共用 :8766,是為了證明「只驗 origin」漏掉了什麼。不要把真正任意生成的 HTML 與可信 widget 放在同一來源:同源腳本可能互相存取,進而破壞你以為獨立的窗口。
宿主在 :8765,第三個埠 :8767 用來測「原本指定的視窗改了來源」。最後一題再把 preview 改成只有 allow-scripts 的 sandbox。所有伺服器只綁 127.0.0.1;整輪的頁面請求只允許這三個本機埠。
①「收件櫃台」:把下列內容存成 index.html
先建立空資料夾。這份檔案的 valid() 就是 schema(訊息格式契約)檢查器;欄位名稱必須恰好三個,數字字串與多餘欄位都拒絕。這個練習刻意只處理高度,不包含正式系統需要的訊息速率上限、收據保存上限及工作階段撤銷。
<!doctype html><meta charset="utf-8"><title>postMessage 本機驗收</title>
<style>body{font:20px system-ui;max-width:1100px;margin:35px auto;color:#172331;background:#f5f8fc}iframe{width:500px;height:230px;border:2px solid #61758c;border-radius:12px}section{display:flex;gap:28px}pre{font-size:16px;white-space:pre-wrap}h1{font-size:34px}</style>
<h1>postMessage 本機驗收|全是假資料</h1><p>宿主狀態:<strong id="status">尚未接受訊息</strong></p>
<section><div><h2>指定元件 widget</h2><iframe id="widget" title="指定元件" sandbox="allow-scripts allow-same-origin" src="http://127.0.0.1:8766/sender.html"></iframe></div>
<div><h2>非指定測試 frame</h2><iframe id="preview" title="非指定測試 frame" sandbox="allow-scripts allow-same-origin" src="http://127.0.0.1:8766/sender.html"></iframe></div></section><pre id="log"></pre>
<script>
const widget = document.querySelector('#widget');
const ALLOWED = 'http://127.0.0.1:8766';
window.receipts = [];
function valid(d) {
return d !== null && typeof d === 'object' && !Array.isArray(d)
&& Object.keys(d).sort().join(',') === 'height,type,v'
&& d.type === 'demo:resize' && d.v === 1
&& Number.isInteger(d.height) && d.height >= 80 && d.height <= 400;
}
addEventListener('message', e => {
let reason = 'accepted';
if (e.source !== widget.contentWindow) reason = 'source';
else if (e.origin !== ALLOWED) reason = 'origin';
else if (!valid(e.data)) reason = 'schema';
if (reason === 'accepted') {
widget.style.height = e.data.height + 'px';
document.querySelector('#status').textContent = '已接受高度 ' + e.data.height;
}
receipts.push({reason, origin:e.origin, height:widget.style.height});
document.querySelector('#log').textContent = receipts.map(x => JSON.stringify(x)).join('\n');
});
</script>
②「只寄假資料的子頁」:存成 sender.html
兩個按鈕只差在 height 是數字還是字串。發送的目的地寫完整宿主 origin,不用 "*"。子頁的 CSP(內容安全政策,限制資源載入等行為)只為這份可複製的本機夾具允許內嵌腳本與樣式;正式部署應依實際檔案採 hash 或 nonce 等較嚴格的腳本政策。
<!doctype html><meta charset="utf-8"><title>假資料發送頁</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<style>body{font:20px system-ui;padding:15px;background:white;color:#172331}button{font:18px system-ui;padding:10px;margin:5px}</style>
<p>只發送高度數字,不含帳戶或密鑰。</p><button id="good">合法通知</button><button id="bad">錯誤格式</button>
<script>
const HOST = 'http://127.0.0.1:8765';
document.querySelector('#good').onclick = () => parent.postMessage({type:'demo:resize',v:1,height:200},HOST);
document.querySelector('#bad').onclick = () => parent.postMessage({type:'demo:resize',v:1,height:'200'},HOST);
</script>
三、postMessage 安全實作:用十題追出每一道邊界
先看前三題,你就能理解整套檢查。正向題必須讓高度變成 200px;反向題除了留下拒絕原因,還必須驗證高度保持原值。只看「有沒有報錯」,會錯過已經發生的狀態改動。
- 正確元件、來源與格式:widget 送整數 200,預期
accepted。 - 同來源的非指定 frame:preview 送合法格式與 250,預期
source,高度仍是 200px。 - 指定元件送錯格式:widget 送字串
"250",預期schema,高度不變。
接著補上 null、陣列、多餘欄位、超出高度上限及錯誤版本,五題都應在 schema 被拒絕。第九題讓 widget 導向 :8767 再發訊息:它仍是指定的瀏覽視窗,來源卻不同,預期 origin。第十題讓 preview 在不含 allow-same-origin 的 sandbox 重新載入,觀察來源變成字串 "null",並繼續因非指定視窗而拒絕。
這個順序也能幫你排錯:同一封信可能同時錯好幾項,收據只記第一個失敗原因。看到 source,不表示格式已通過;它表示程式更早就停止處理。
③「自動驗收員」:存成 run.cjs
下面的腳本啟動三個本機伺服器、開啟瀏覽器、等待導頁完成,再逐題送訊與核對結果。成功輸出十個 pass: true,並產生 results.json 與 receipts.png;斷言失敗則以非零狀態離開。用完會關閉瀏覽器與這次建立的伺服器。
const {createServer}=require('node:http');
const {readFileSync,writeFileSync,mkdirSync}=require('node:fs');
const path=require('node:path');
const assert=require('node:assert/strict');
const puppeteer=require('puppeteer-core');
const servers=[];let browser;
(async()=>{
for(const port of [8765,8766,8767]) {
const s=createServer((req,res)=>{
const file=req.url.split('?')[0]==='/sender.html'?'sender.html':'index.html';
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'});
res.end(readFileSync(path.join(__dirname,file)));
});
await new Promise((resolve,reject)=>{s.on('error',reject);s.listen(port,'127.0.0.1',resolve)});servers.push(s);
}
browser=await puppeteer.launch({executablePath:process.env.CHROME_BIN,headless:true});
const page=await browser.newPage();await page.setViewport({width:1200,height:720});
const network=[];await page.setRequestInterception(true);
page.on('request',r=>{network.push(r.url());if(!/^http:\/\/127\.0\.0\.1:876[567]\//.test(r.url()))r.abort();else r.continue()});
await page.goto('http://127.0.0.1:8765/',{waitUntil:'networkidle0'});
const frame=async id=>(await page.$('#'+id)).contentFrame();
const good={type:'demo:resize',v:1,height:200};const results=[];
async function send(name,id,data,expected){
const before=await page.evaluate(()=>({n:receipts.length,h:widget.style.height}));
await (await frame(id)).evaluate(d=>parent.postMessage(d,'http://127.0.0.1:8765'),data);
await page.waitForFunction(n=>receipts.length===n+1,{},before.n);
const result=await page.evaluate(()=>receipts.at(-1));assert.equal(result.reason,expected);
if(expected!=='accepted')assert.equal(result.height,before.h);
results.push({name,expected,...result,pass:true});
}
await send('valid widget','widget',good,'accepted');
await send('same-origin sibling','preview',{...good,height:250},'source');
await send('wrong scalar type','widget',{...good,height:'250'},'schema');
await send('null','widget',null,'schema');
await send('array','widget',[],'schema');
await send('extra field','widget',{...good,html:'fake text'},'schema');
await send('out-of-range','widget',{...good,height:10000},'schema');
await send('wrong version','widget',{...good,v:2},'schema');
await Promise.all([(await frame('widget')).waitForNavigation({waitUntil:'load'}), page.evaluate(()=>widget.src='http://127.0.0.1:8767/sender.html')]);
await send('expected window wrong origin','widget',{...good,height:250},'origin');
await Promise.all([(await frame('preview')).waitForNavigation({waitUntil:'load'}),page.evaluate(()=>{const p=document.querySelector('#preview');p.setAttribute('sandbox','allow-scripts');p.src='http://127.0.0.1:8766/sender.html?opaque=1'})]);
await send('opaque preview','preview',{...good,height:250},'source');
assert.equal(results.at(-1).origin,'null');
await page.screenshot({path:path.join(__dirname,'receipts.png')});
writeFileSync(path.join(__dirname,'results.json'),JSON.stringify({date:new Date().toISOString(),browser:await browser.version(),results,network},null,2));
console.log(JSON.stringify({browser:await browser.version(),passed:results.length,results},null,2));
})().catch(e=>{console.error(e);process.exitCode=1}).finally(async()=>{if(browser)await browser.close();for(const s of servers)s.close()});
④「按一次就重跑」:安裝局部測試依賴,再執行
把三份檔案放進同一資料夾;在該資料夾執行以下指令。範例固定使用 puppeteer-core@25.16.0,它是控制瀏覽器的程式庫;官方安裝文件說明 core 版本不下載 Chrome,因此你要透過 CHROME_BIN 指到已有的瀏覽器執行檔。
npm init -y
npm install --save-dev puppeteer-core@25.16.0
CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node run.cjs
上面最後一行是 macOS 已安裝 Google Chrome 的路徑;Linux 換成自己已安裝的 Chrome/Chromium 執行檔路徑。AlphaLab 這次以 /opt/homebrew/bin/chrome-headless-shell 作為 CHROME_BIN。若 8765、8766 或 8767 已被其他程式使用,先停止這次練習,改用三個空閒埠,並同步改三份檔案裡的地址與請求白名單。
本次十題全部符合上述預期:一題接受、九題拒絕,九次拒絕後的高度都保持 200px。這份成績描述的是這十個固定輸入與本機瀏覽器;跨瀏覽器、訊息洪水、第三方依賴與正式帳戶權限,需要各自新增測例,不能由這一輪代答。

四、opaque origin 怎麼辦?不要把 null 放進共同信任名單
Sandbox 不含 allow-same-origin 時,文件會被放進 opaque origin(不透明來源,不能當作一般網站地址識別的來源)。在本例的訊息事件中,它序列化為 "null"。iframe 的 sandbox 文件說明這個旗標的效果;不同受限文件都可能呈現這個字串,不能據此識別某一個可信產品。
本例的保守選擇,是完全不讓生成預覽參與可信 widget 的契約。如果產品需要讓 opaque 預覽回報尺寸,可以另外開一個只有尺寸能力的協定:綁定當次 frame 的 contentWindow、在受控建立流程分配通道或 session 識別碼、限制訊息種類與範圍,導頁或重建時撤銷舊通道。不要把「收到 null」改成接受所有 null。
即使使用隨機 token 或 MessageChannel,預覽中的任意腳本仍可能使用你交給它的能力。因此通道只應授予預覽本來就能做的最小操作,不放帳戶資料、任意 URL 導向、HTML 插入或後端寫入權。本文沒有實作這個額外協定;本輪驗證的政策是拒絕 preview。
五、sandbox、CSP 與 HTML 寫入點,各守自己的位置
你可以從「讓它能做什麼」逐層縮小:不需要腳本的生成 HTML,用不開啟腳本的 sandbox;需要互動時,再逐項開啟必要權限。MDN 特別提醒,同源內容同時有 allow-scripts 與 allow-same-origin 時,可能移除 sandbox 限制。把內容放進 iframe,仍要核對它最後實際取得的來源與旗標。
CSP 管的是腳本、圖片、連線等資源與執行政策;它與收件人的 source、origin 和 schema 檢查分工不同。MDN 的 CSP 指引也區分 HTTP header 與 meta 的支援範圍;正式預覽環境應由你控制的伺服器提供政策,不能假設讓不可信 HTML 自己附上一段 meta 就完成限制。
最後沿每個 listener 往後追,找 innerHTML、srcdoc 等會把字串當成 HTML 的寫入點。srcdoc 文件指出,未適當隔離的 srcdoc 可能與父頁同源。OWASP 的 Web Messaging 建議則要求將訊息當資料,純文字顯示採 textContent。本例把高度限制成整數後只設定高度,收據也用 textContent 顯示。
Trusted Types(限制敏感 DOM 寫入接受特定型別的機制)可以把處理流程集中起來,但「標成 TrustedHTML」不是內容自動變乾淨。若建立 policy 的函式只是把輸入原樣回傳,它仍缺少你需要的內容清理。清理、隔離與能力授權應分別驗證。
六、接進回歸檢查:把「有防護」變成會失敗的測試
把三份檔案與套件鎖定檔留在自己的測試專案,CI(每次改版自動跑檢查的流程)也執行同一個 node run.cjs。固定 CI 的瀏覽器版本,保存 JSON 與截圖;當接收器、元件依賴、sandbox、來源地址或訊息版本變更時重跑。這樣你改了哪一道門,都有對應考題。
再檢查測試本身有沒有敏感度:只在一次性副本暫時移除 source 檢查,第二題應失敗;移除 origin 檢查,第九題應失敗;移除 schema 檢查,錯誤格式題應失敗。看見預期的失敗後,還原防護再重跑。不要把弱化版本部署到有真實資料的環境。
如果既有系統還會下載檔案、導向網址或呼叫 API,將每一項副作用另列允許條件,並在真正執行處授權。訊息格式正確不能替使用者取得別人的資料。可以對照 Prompt Injection 回歸測試的「輸入、執行與最終狀態」分層,再用 Agent Harness 實作把檢查接進系統。
常見問題:八個先講答案的判斷
1. iframe 裡的內容碰不到父頁,就安全了嗎?
還要查訊息入口。瀏覽器限制直接讀取父頁,和父頁 listener 是否願意代做某件事,是兩個機制。
2. origin 對了,為什麼還要驗 source?
同一來源可能有多個 frame。契約通常只授權某一個;但若不可信腳本已能控制指定 frame,這兩道檢查也不是內容可信的保證。
3. sandbox 的 null 可以加入 allowlist 嗎?
不要把它當成產品身分。若需要支援 opaque 預覽,另做綁定當次視窗、權限很小且可撤銷的通道;本例直接拒絕。
4. 傳 JSON 物件就不會有問題嗎?
仍要驗欄位與操作。錯誤版本、超界數值與多餘欄位,都可以是看起來正常的物件。
5. targetOrigin 寫星號比較省事嗎?
本例知道宿主地址,所以寫完整地址。涉及機密訊息時更不能用星號把收件來源限制拿掉。
6. CSP 能代替訊息檢查嗎?
分別設定。CSP 限制資源與執行,接收器則決定接受誰提出哪一種操作。
7. 十題都過,表示真實網站驗完了嗎?
只表示這十題符合預期。正式系統的其他 listener、導航、資料權限與訊息速率,還要各有可判定的測例。
8. 我一定要讓預覽發訊息嗎?
從需求決定。只顯示靜態作品時,可先不提供宿主操作通道;有尺寸或互動需求,再新增一種最小能力與對應測試。
給新手的三個重點
- 先畫邊界:可信宿主、指定元件與任意生成內容,不共用不必要的權限。
- 再驗契約:指定視窗、允許來源、格式與範圍,都在修改狀態前檢查。
- 最後留收據:正向題要成功,反向題要拒絕,而且拒絕後狀態不變。
接著閱讀
左右滑動查看更多推薦
下一步:今天先讓一封不該接受的訊息被拒絕
先完成同來源的非指定 frame 那一題,親眼看見 source 拒絕,且高度不變;再補格式與來源改變的案例。記住這句話:可接受的訊息=指定視窗+允許來源+有限契約。如果你正準備把 AI 作品接進自己的服務,可從 AlphaLab AI 課程繼續練習,也可回到 AI 教學與分析專區找下一個需要建立的邊界。






