Actual Sync Server: CORSプロキシ、GitHub APIの許可リスト、プレフィックスのバイパス、サーバー経由でのプライベートリポジトリへのアクセス、GitHubトークン
## 概要
Actual Sync ServerのCORSプロキシは、認証されたユーザーが、公式のプラグイン許可リストに登録されているリポジトリからのみリソースを取得できるように設計されています。 `ACTUAL_GITHUB_TOKEN`が設定されている場合、プロキシはサーバーのGitHubトークンを自動的にGitHubリクエストに付加します。
GitHub APIの許可リストのチェックでは、`/repos/{owner}/{repo}`に対して、リポジトリ名の後にパスの区切りが必要ない、単純な`startsWith()`によるプレフィックステストが行われます。 許可リストに登録されている公開プラグインリポジトリが`https://github.com/acme/plugin`の場合、プロキシは以下のGitHub APIのURLも受け入れます。
```text
https://api.github.com/repos/acme/plugin-private/contents/.env
https://api.github.com/repos/acme/plugin-secrets/actions/secrets
https://api.github.com/repos/acme/plugin-internal/releases
```
これらのURLは許可リストに登録されているリポジトリの範囲外ですが、APIパスが`/repos/acme/plugin`で始まるため、許可されてしまいます。 プロキシは、このリクエストをサーバーの`ACTUAL_GITHUB_TOKEN`とともに転送し、認証されたActualユーザーであれば、そのトークンでアクセス可能なプライベートなGitHubリソースを読み取ることができます。
## 影響を受けるエンドポイント
- `GET /cors-proxy?url=...`
このエンドポイントは、以下の条件でのみ有効になります。
```js
if (config.get('corsProxy.enabled')) {
app.use('/cors-proxy', corsApp.handlers);
}
```
出典: `packages/sync-server/src/app.ts:68-70`
## 詳細
CORSプロキシはデフォルトで無効になっていますが、`ACTUAL_CORS_PROXY_ENABLED`を通じて有効にすることができます。 GitHubトークンは、`ACTUAL_GITHUB_TOKEN`を通じて設定されます。
```js
github: {
token: {
doc: 'API認証のためのGitHub Personal Access Token。',
format: String,
default: '',
env: 'ACTUAL_GITHUB_TOKEN',
},
},
corsProxy: {
enabled: {
doc: 'CORSプロキシのエンドポイントを有効にする。',
format: Boolean,
default: false,
env: 'ACTUAL_CORS_PROXY_ENABLED',
},
},
```
出典: `packages/sync-server/src/load-config.js:280-296`
プロキシは、プラグインの許可リストを取得し、リポジトリのURLを保存します。
```js
const response = await fetch(
'https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json',
);
...
const plugins = await response.json();
allowlistedRepos = plugins.map(plugin => plugin.url);
```
ソース: `packages/sync-server/src/app-cors-proxy.js:43-50`
GitHub APIのチェックでは、`/repos/${repoOwner}/${repoName}`で始まるパスがすべて許可されます。
```js
for (const repoUrl of allowlistedRepos) {
const { pathname } = new URL(repoUrl);
const [, repoOwner, repoName] = pathname.split('/');
if (
targetUrl === repoUrl ||
targetUrl.startsWith(repoUrl + '/') ||
(hostname === 'api.github.com' &&
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)) ||
...
) {
return true;
}
}
```
ソース: `packages/sync-server/src/app-cors-proxy.js:84-99`
`api.github.com`の場合、`repoName`の後に区切り文字はありません。これは、`plugin`という名前の許可されたリポジトリが、同じオーナーを持つ`plugin-private`、`plugin-secrets`、`plugin-internal`などのリポジトリ、および`plugin`で始まる名前を持つ他のすべてのリポジトリに対するAPIリクエストを許可することを意味します。
この誤った許可リストの決定の後、プロキシはサーバーのGitHubトークンを付加します。
```js
const githubToken = config.get('github.token');
if (
githubToken &&
(url.hostname === 'api.github.com' ||
url.hostname === 'raw.githubusercontent.com' ||
(url.hostname === 'github.com' && url.pathname.includes('/releases/')))
) {
requestHeaders['Authorization'] = `Bearer ${githubToken}`;
requestHeaders['User-Agent'] = 'Actual-Budget-Plugin-System';
}
```
ソース: `packages/sync-server/src/app-cors-proxy.js:192-201`
したがって、脆弱なフローは次のようになります。
1. 公開プラグインのリポジトリが許可リストに登録されています。例:`https://github.com/acme/plugin`。
2. 同じ所有者が、プレフィックスが一致する名前を持つプライベートリポジトリを持っています。例:`acme/plugin-private`。
3. Actualサーバーでは、`ACTUAL_CORS_PROXY_ENABLED=true`が設定されています。
4. Actualサーバーでは、`ACTUAL_GITHUB_TOKEN`が設定されており、`acme/plugin-private`へのアクセス権があります。
5. 認証されたすべてのActualユーザーが、以下のリクエストを送信します。
```text
/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env
```
6. `isUrlAllowed()`関数は、`/repos/acme/plugin-private/...`が`/repos/acme/plugin`で始まるため、trueを返します。
7. プロキシは、サーバーのトークンを使用して、GitHubへのリクエストを送信します。
8. レスポンスは、権限の低いActualユーザーに返されます。
## 実証コード
### 前提条件
1. `ACTUAL_CORS_PROXY_ENABLED=true`が設定されていること。
2. `ACTUAL_GITHUB_TOKEN`が設定されており、プライベートリポジトリを読み取れること。
3. 公式のプラグイン許可リストに、プライベートのターゲットリポジトリの所有者名とリポジトリ名がプレフィックスとして含まれる公開リポジトリが登録されていること。
4. 攻撃者が、有効なActualセッショントークンを持っていること。
例:
- 許可リストに登録された公開リポジトリ:`https://github.com/acme/plugin`
- プライベートのターゲットリポジトリ:`https://github.com/acme/plugin-private`
- プライベートファイル:`.env`
### 手動での再現
ステップ1:Actual CORSプロキシを通じて、プライベートリポジトリのファイルをリクエストします。
```bash
curl -s "http://TARGET_HOST:5006/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env" \
-H "X-Actual-Token: LOW_PRIVILEGED_ACTUAL_SESSION"
```
脆弱性のある結果:
```json
{
"name": ".env",
"path": ".env",
"encoding": "base64",
"content": "UFJPRF9EQl9QQVNTV09SRD0uLi4="
}
```
ステップ2:返された`content`フィールドをデコードします。
```bash
echo "UFJPRF9EQl9QQVNTV09SRD0uLi4=" | base64 -d
```
デコードされた出力の例:
```text
PROD_DB_PASSWORD=...
```
### Pythonによる実証コード
別途、`poc_actual_cors_proxy_github_prefix_bypass.py`として添付されています。
PoC(概念実証)では、GitHubトークンは不要です。代わりに、ターゲットのActualサーバーを基準として使用します。もしサーバーのトークンが、プレフィックス一致するプライベートリポジトリにアクセスできる場合、GitHubのAPIレスポンスが、権限の低いActualユーザーに返されます。
## 影響
- 認証されたすべてのActualユーザーが、プラグインの許可リストに含まれていない、プレフィックス一致するリポジトリに対して、サーバーのGitHubトークンを使用できる可能性があります。
- プライベートのソースコード、リポジトリのメタデータ、リリースデータ、デプロイメントファイル、および誤ってコミットされたシークレットが公開される可能性があります。
- トークンが、広範な組織またはユーザーリポジトリに対する読み取り権限を持っている場合、許可リストに含まれている公開リポジトリが、同じ名前のプレフィックスを共有する複数のプライベートリポジトリへの足掛かりとなる可能性があります。
- 公開されたリポジトリのシークレットやデプロイメント関連の情報は、本番環境のインフラストラクチャやサプライチェーンのリリース資産に対するさらなる攻撃を可能にする可能性があります。
## 推奨される対策
- GitHub APIのリポジトリパスのセグメントを正確に解析し、比較してください。
- 以下のコードを置き換えてください。
```js
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)
```
以下の、境界を考慮した正確なチェックに置き換えます。
```js
url.pathname === `/repos/${repoOwner}/${repoName}` ||
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}/`)
```
- 許可リストに含まれるすべてのブランチに対して、同様の境界チェックを適用してください。
- 許可リストに含まれる`owner/plugin`が、`owner/plugin-private`へのアクセスを許可しないことを示す回帰テストを追加してください。
- 許可されたリポジトリと完全に一致しない限り、ユーザー主導のプロキシリクエストに対して`ACTUAL_GITHUB_TOKEN`を付与しないことを検討してください。
影響を受けるパッケージ:
- npm (JavaScript) @actual-app/sync-server: 影響を受けるバージョンは26.6.0以下、修正は26.7.0で実施済み。