透過 Sanitizer API 進行 Safe DOM 操控

新版 Sanitizer API 旨在建立可靠的處理器,讓您可將任意字串安全地插入網頁。

Jack J
Jack J

應用程式會持續處理不受信任的字串,但要安全地將這類內容算作 HTML 文件的一部分,可能會相當棘手。如果不加以注意,很容易不小心讓跨網站指令碼攻擊 (XSS) 出現,讓惡意攻擊者有機可乘。

為降低這類風險,新的 Sanitizer API 提案旨在建立可靠的處理器,讓任意字串可安全地插入網頁。本文將介紹 API 並說明其用法。

// Expanded Safely !!
$div.setHTML(`<em>hello world</em><img src="" onerror=alert(0)>`, new Sanitizer())

為使用者輸入內容加上逃逸字元

將使用者輸入內容、查詢字串、Cookie 內容等插入 DOM 時,必須妥善轉義字串。請特別留意透過 .innerHTML 進行的 DOM 操控,因為未經轉義的字串是 XSS 的典型來源。

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.innerHTML = user_input

如果您在上述輸入字串中逸出 HTML 特殊字元,或使用 .textContent 展開字串,alert(0) 就不會執行。不過,由於使用者新增的 <em> 也會以字串形式展開,因此無法使用這個方法保留 HTML 中的文字修飾。

在此情況下,最佳做法並非轉義,而是清理

清理使用者輸入內容

轉義和清理之間的差異

逸出是指將特殊 HTML 字元替換為 HTML 實體

清理是指從 HTML 字串中移除語意有害的部分 (例如指令碼執行)。

範例

在前述範例中,<img onerror> 會導致錯誤處理常式執行,但如果移除 onerror 處理常式,則可以在 DOM 中安全地展開,同時保留 <em>

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`
// Sanitized ⛑
$div.innerHTML = `<em>hello world</em><img src="">`

如要正確處理,您必須將輸入字串解析為 HTML,並省略視為有害的標記和屬性,保留無害的標記和屬性。

建議的 Sanitizer API 規格旨在為瀏覽器提供這類處理作業的標準 API。

Sanitizer API

Sanitizer API 的使用方式如下:

const $div = document.querySelector('div')
const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.setHTML(user_input, { sanitizer: new Sanitizer() }) // <div><em>hello world</em><img src=""></div>

不過,{ sanitizer: new Sanitizer() } 是預設引數。因此,可以像以下這樣。

$div.setHTML(user_input) // <div><em>hello world</em><img src=""></div>

值得注意的是,setHTML() 是在 Element 上定義。由於 Element 是方法,因此剖析的內容會自行解釋 (在本例中為 <div>),剖析作業會在內部執行一次,結果會直接展開至 DOM。

如要將清理結果做為字串,您可以使用 setHTML() 結果中的 .innerHTML

const $div = document.createElement('div')
$div.setHTML(user_input)
$div.innerHTML // <em>hello world</em><img src="">

透過設定自訂

Sanitizer API 預設會移除會觸發指令碼執行的字串。不過,您也可以透過設定物件,在清理程序中加入自訂內容。

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allowComments: true
};
// sanitized result is customized by configuration
new Sanitizer(config)

下列選項會指定清理結果應如何處理指定元素。

allowElements:消毒工具應保留的元素名稱。

blockElements:消毒工具應移除的元素名稱,同時保留其子項。

dropElements:消毒器應移除的元素名稱及其子項。

const str = `hello <b><i>world</i></b>`

$div.setHTML(str)
// <div>hello <b><i>world</i></b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: [ "b" ]}) })
// <div>hello <b>world</b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({blockElements: [ "b" ]}) })
// <div>hello <i>world</i></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: []}) })
// <div>hello world</div>

您也可以透過下列選項,控制清理器是否允許或拒絕指定的屬性:

  • allowAttributes
  • dropAttributes

allowAttributesdropAttributes 屬性預期會收到屬性比對清單,也就是鍵為屬性名稱、值為目標元素清單或 * 萬用字元的物件。

const str = `<span id=foo class=bar style="color: red">hello</span>`

$div.setHTML(str)
// <div><span id="foo" class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["span"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["p"]}}) })
// <div><span>hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["*"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({dropAttributes: {"id": ["span"]}}) })
// <div><span class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {}}) })
// <div>hello</div>

allowCustomElements 是允許或拒絕自訂元素的選項。如果允許,元素和屬性的其他設定仍會套用。

const str = `<custom-elem>hello</custom-elem>`

$div.setHTML(str)
// <div></div>

const sanitizer = new Sanitizer({
  allowCustomElements: true,
  allowElements: ["div", "custom-elem"]
})
$div.setHTML(str, { sanitizer })
// <div><custom-elem>hello</custom-elem></div>

API 介面

與 DomPurify 的比較

DOMPurify 是提供消毒功能的知名程式庫。Sanitizer API 與 DOMPurify 之間的主要差異在於,DOMPurify 會以字串格式傳回清理結果,您必須透過 .innerHTML 將其寫入 DOM 元素。

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sanitized
// `<em>hello world</em><img src="">`

如果瀏覽器未實作 Sanitizer API,DOMPurify 可做為備用方案。

DOMPurify 實作方式有幾個缺點。如果傳回字串,則系統會透過 DOMPurify 和 .innerHTML 剖析輸入字串兩次。這種雙重剖析會浪費處理時間,但也可能導致第二次剖析結果與第一次不同,進而造成有趣的安全漏洞。

HTML 也需要上下文才能進行剖析。舉例來說,<td> 適用於 <table>,但不適用於 <div>。由於 DOMPurify.sanitize() 只會將字串做為引數,因此必須猜測剖析內容。

Sanitizer API 是 DOMPurify 方法的改良版,可避免重複剖析,並明確指出剖析內容。

API 狀態和瀏覽器支援

Sanitizer API 目前正在標準化程序中討論中,Chrome 也正在實作中。

步驟 狀態
1. 建立說明 完成
2. 建立規格草稿 完成
3. 收集意見回饋並重複設計 完成
4. Chrome 來源試用版 完成
5. 啟動 Intent to Ship on M105

Mozilla:認為此提案值得製作原型,並積極實施

WebKit:請參閱 WebKit 聯絡清單上的回應。

如何啟用 Sanitizer API

透過 about://flags 或 CLI 選項啟用

Chrome

Chrome 正在實作 Sanitizer API。在 Chrome 93 以上版本中,您可以啟用 about://flags/#enable-experimental-web-platform-features 標記來試用這項功能。在舊版 Chrome Canary 和開發人員頻道中,您可以透過 --enable-blink-features=SanitizerAPI 啟用這項功能,並立即試用。請參閱操作說明,瞭解如何使用旗標執行 Chrome。

Firefox

Firefox 也將 Sanitizer API 實作為實驗功能。如要啟用這項功能,請在 about:config 中將 dom.security.sanitizer.enabled 旗標設為 true

特徵偵測

if (window.Sanitizer) {
  // Sanitizer API is enabled
}

意見回饋

如果您試用這個 API 後有任何意見,歡迎與我們分享。請在 Sanitizer API GitHub 問題中分享您的想法,並與規格作者和對此 API 感興趣的人士討論。

如果在 Chrome 實作中發現任何錯誤或異常行為,請回報錯誤。選取 Blink>SecurityFeature>SanitizerAPI 元件並分享詳細資料,協助實作人員追蹤問題。

示範

如要瞭解 Sanitizer API 的實際運作情形,請參閱 Mike West 製作的 Sanitizer API Playground

參考資料


相片來源:Towfiqu barbhuiyaUnsplash 網站上提供。