前言
完成 Block Runtime 重構後,我開始替 KnockersTaskBlocks 加入第一個真正的重型區塊:互動式 HTML。
需求是讓使用者在 Kanboard 任務中貼上一份完整 HTML 文件,內容可以包含 CSS、JavaScript 與外部資源。編輯時要同時看到原始碼和預覽,任務摘要、唯讀頁與公開頁也要能呈現,並支援全螢幕查看。
一開始我以為這只是「程式碼欄位+iframe」。實際做下去後,問題很快變成:如何執行一段完全不信任的 JavaScript,同時不讓它取得 Kanboard 的登入狀態、任務資料與父頁控制權?
原本想沿用另一個產品的 HTML 預覽
另一個內部產品已經有 HTML 原始碼編輯、iframe 預覽、全螢幕、Esc 關閉與 body scroll lock,因此第一個方向是抽出既有元件。
研究後發現,可重用的是互動殼,原本的內容安全策略卻不能直接搬過來。既有 safeHtmlDocument 會使用 sanitizer 移除 JavaScript 與部分外部資源,適合靜態預覽,但和這次「明確允許作者執行 JavaScript」的需求相反。
這裡形成第一個重要決策:
重用來源碼編輯與預覽體驗,但重新設計執行環境;不能因為 UI 類似,就假設安全邊界也相同。
第一個原型:使用 iframe srcdoc
最直覺的方案是把完整 HTML 放進 srcdoc:
<iframe
sandbox="allow-scripts"
srcdoc="<!doctype html>..."
></iframe>
它不需要暫存檔或額外 endpoint,更新 srcdoc 就能重新執行內容,看起來很適合編輯預覽。
但目前 Kanboard 主頁有嚴格的 Content Security Policy,而且一般頁面不允許被任意嵌入。srcdoc 這類 local-scheme 文件會受到嵌入頁 CSP 影響,結果是使用者內容中的 inline JavaScript 和外部 script 無法依需求執行。
可以選擇放寬 Kanboard 全站 CSP,但那等於為了不可信任的內容,降低整個登入後台的安全政策。這個方向很快被排除。
問題不是既有 CSP 設定錯誤,而是可信任的應用程式與不可信任的使用者程式碼,本來就不應共用同一份執行政策。
也不能把 HTML 直接插進任務頁
如果把 HTML 直接插入外層 DOM,風險更加明顯:
- CSS 可以覆蓋 Kanboard 表單、按鈕與版面。
- JavaScript 可能讀取父頁 DOM 與已載入的任務資料。
- 同源程式可能呼叫登入使用者有權限的 API。
- 使用者內容可以干擾導覽、表單與對話框。
因此 iframe 不是單純為了避免 CSS 跑版,而是整個執行邊界。真正要調整的是 iframe 裡載入的文件來源。
最後採用獨立 Runner 文件
最終架構改成一個不包含任務資料的通用 runner route:
Kanboard 任務頁
│
├─ 建立 sandbox iframe
│ │
│ └─ 載入獨立 Runner 文件
│
└─ postMessage 傳入 HTML 草稿
│
└─ Runner 驗證後建立乾淨文件執行
Runner 不查詢 Task Document、不接收帶有 HTML 的 query string,也不把內容記入 server log。它只輸出最小執行殼,等父頁載入後再接收訊息。
這讓 runner response 可以擁有自己的 CSP:主系統繼續維持嚴格政策,runner 則只開放作者內容需要的 inline/external CSS、JavaScript、圖片、字型與媒體。
Sandbox 只開放 JavaScript
iframe 使用:
<iframe sandbox="allow-scripts"></iframe>
刻意不加入 allow-same-origin。即使 runner route 位於同一個網站,sandbox 內的文件仍被視為 opaque origin,不能直接讀取父站 Cookie、localStorage、sessionStorage 或 DOM。
同時不開放:
- Form submission
- Popup
- Top navigation
- Download
- Modal
- 父頁 DOM 與持久同源儲存
sandbox 不是「安全」的同義詞,而是一組能力開關。這個案例只開放執行 JavaScript 所需的最小能力,其餘功能沒有需求就不提供。
postMessage 也需要自己的協定
父頁不能只送出一段 HTML 字串。實作使用有版本的訊息格式:
{
"type": "task-blocks:render-html",
"protocolVersion": 1,
"channelId": "random-per-iframe",
"html": "<!doctype html>..."
}
Runner 收到訊息後會確認:
- 訊息來自預期父視窗。
type與 protocol version 正確。channelId屬於目前 iframe instance。- HTML 是字串且沒有超過 byte limit。
- 初始化訊息沒有被重複處理。
每個 iframe 都使用不同 channel,避免同一個任務頁的多個 HTML 區塊互相接收內容。Block version 和通訊協定版本也分開管理,未來可以調整 message protocol,而不必改寫已儲存文件。
草稿和正在執行的內容必須分開
如果每輸入一個字就重新執行 JavaScript,頁面會不斷建立 timer、listener 與外部請求,也很難理解目前預覽對應哪一份草稿。
最後使用兩份狀態:
draftHtml:使用者目前正在編輯的內容。renderedHtml:最近一次送進 runner 的完整快照。
停止輸入 500ms 後才更新預覽,也提供手動重新整理。每次刷新都銷毀舊 iframe、建立新 runner,讓 JavaScript 從乾淨環境重新執行。
全螢幕切換同樣會重建 runtime,因此 HTML 內尚未保存的執行狀態會重置。這是刻意的產品行為,而不是偶發副作用。
公開頁不能自動執行任意程式
登入後的編輯、摘要與唯讀頁,可以在既有權限範圍內直接啟動 runner;匿名公開頁則採更保守的流程。
訪客第一次開啟時先取得 script-disabled 的靜態預覽,確認內容後再主動按下「啟用互動內容」。只有這次頁面 instance 會重新建立允許 script 的 runner,Token 本身不會被傳進作者 HTML 或 postMessage payload。
這無法判斷 HTML 是否具有惡意,但至少不會讓任何取得公開連結的人在開頁瞬間自動執行第三方程式。
實際驗證了哪些行為?
原型除了比較左右工作台、預覽優先與程式碼工作區三種介面,也在接近正式 CSP 的環境驗證:
- Inline JavaScript 可以執行。
- CDN 外部套件可以載入。
- iframe 無法讀取 Kanboard 父頁 DOM。
- iframe 無法使用父站
localStorage。 - 同一區塊同時只有一個 active iframe。
- 500ms 更新與手動刷新正常。
- 全螢幕、關閉與
Esc正常。 - JavaScript runtime error 留在區塊內,不讓任務整頁失效。
編輯介面最後選擇左右工作台,因為來源與結果可以同時比較;任務摘要則採較大的預覽層級。程式碼密度最高的深色工作區保留為可能的進階模式,不放進第一版。
仍然存在的風險
這套架構降低的是父系統權限,不代表使用者程式碼變成可信任內容。它仍可能:
- 向外部伺服器發送自己已知的資料。
- 載入帶有追蹤能力的第三方 script。
- 執行大量運算或建立大量請求。
- 顯示誤導性的登入、付款或下載介面。
- 因為外部資源、CORS 或 mixed content 而失效。
因此文章中的「隔離」指的是不讓內容取得 Kanboard 的 DOM、Cookie、Storage 與導覽能力,不是完整的惡意程式分析或內容審核系統。
參考規範
- W3C Content Security Policy Level 3:local scheme 文件的 CSP inheritance
- WHATWG HTML Standard:iframe sandbox
- WHATWG HTML Standard:Cross-document messaging
下一篇會進入另一種重型內容:Kanboard 跨職能流程圖外掛開發實錄:React Flow 資料模型與三欄工作台。
