CVE-2026-57449: Actual Sync Server CORS Proxy GitHub API Allowlist Vulnerability
HIGHCVE-2026-57449 is a HIGH cors proxy github api allowlist vulnerability (CWE-284: Improper Access Control) affecting Actual Sync Server libraries in the npm ecosystem. The issue involves cors proxy github api allowlist prefix bypass leaks private repositories through the server github token. Remediation is available by upgrading affected packages to version 26.7.0.
CVE-2026-57449 at a Glance
How to Fix CVE-2026-57449
To remediate this vulnerability, upgrade all affected Actual Sync Server dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install @actual-app/sync-server@26.7.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls @actual-app/sync-server
How to Check if You Are Affected
To determine whether your Node.js application is affected by CVE-2026-57449, inspect your project dependencies using npm:
npm ls @actual-app/sync-server
Analyze the terminal output to locate any version of Actual Sync Server in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 26.7.0), your application is affected.
Note: The npm ls @actual-app/sync-server command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-284: Improper Access Control, where the product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. In Actual Sync Server, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-57449 Affected Actual Sync Server Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
@actual-app/sync-server
|
npm |
< 26.7.0
|
26.7.0
|
References
Frequently Asked Questions
What is CVE-2026-57449?
Which Actual Sync Server versions are affected?
What version fixes CVE-2026-57449?
Is @actual-app/sync-server affected?
What CWE is associated with CVE-2026-57449?
How can I check npm dependencies for CVE-2026-57449?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-m62c-5q34-f3cf",
"modified" : "2026-10-07T14:00:45Z",
"published" : "2026-10-07T14:00:45Z",
"aliases" : [ "CVE-2026-57449" ],
"summary" : "Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token",
"details" : "## Summary\n\nActual 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.\n\nThe 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:\n\n```text\nhttps://api.github.com/repos/acme/plugin-private/contents/.env\nhttps://api.github.com/repos/acme/plugin-secrets/actions/secrets\nhttps://api.github.com/repos/acme/plugin-internal/releases\n```\n\nThose 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.\n\n## Affected Endpoint\n\n- `GET /cors-proxy?url=...`\n\nThis endpoint is mounted only when:\n\n```js\nif (config.get('corsProxy.enabled')) {\n app.use('/cors-proxy', corsApp.handlers);\n}\n```\n\nSource: `packages/sync-server/src/app.ts:68-70`\n\n## Details\n\nThe CORS proxy is disabled by default but can be enabled through `ACTUAL_CORS_PROXY_ENABLED`. The GitHub token is configured through `ACTUAL_GITHUB_TOKEN`:\n\n```js\ngithub: {\n token: {\n doc: 'GitHub Personal Access Token for API authentication.',\n format: String,\n default: '',\n env: 'ACTUAL_GITHUB_TOKEN',\n },\n},\ncorsProxy: {\n enabled: {\n doc: 'Enable the CORS proxy endpoint.',\n format: Boolean,\n default: false,\n env: 'ACTUAL_CORS_PROXY_ENABLED',\n },\n},\n```\n\nSource: `packages/sync-server/src/load-config.js:280-296`\n\nThe proxy fetches the plugin allowlist and stores repository URLs:\n\n```js\nconst response = await fetch(\n 'https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json',\n);\n...\nconst plugins = await response.json();\nallowlistedRepos = plugins.map(plugin => plugin.url);\n```\n\nSource: `packages/sync-server/src/app-cors-proxy.js:43-50`\n\nThe GitHub API check accepts any path that starts with `/repos/${repoOwner}/${repoName}`:\n\n```js\nfor (const repoUrl of allowlistedRepos) {\n const { pathname } = new URL(repoUrl);\n const [, repoOwner, repoName] = pathname.split('/');\n\n if (\n targetUrl === repoUrl ||\n targetUrl.startsWith(repoUrl + '/') ||\n (hostname === 'api.github.com' &&\n url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)) ||\n ...\n ) {\n return true;\n }\n}\n```\n\nSource: `packages/sync-server/src/app-cors-proxy.js:84-99`\n\nFor `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`.\n\nAfter this incorrect allowlist decision, the proxy attaches the server's GitHub token:\n\n```js\nconst githubToken = config.get('github.token');\nif (\n githubToken &&\n (url.hostname === 'api.github.com' ||\n url.hostname === 'raw.githubusercontent.com' ||\n (url.hostname === 'github.com' && url.pathname.includes('/releases/')))\n) {\n requestHeaders['Authorization'] = `Bearer ${githubToken}`;\n requestHeaders['User-Agent'] = 'Actual-Budget-Plugin-System';\n}\n```\n\nSource: `packages/sync-server/src/app-cors-proxy.js:192-201`\n\nTherefore the vulnerable flow is:\n\n1. A public plugin repository is allowlisted, for example `https://github.com/acme/plugin`.\n2. The same owner has a private repository with a prefix-matching name, for example `acme/plugin-private`.\n3. The Actual server has `ACTUAL_CORS_PROXY_ENABLED=true`.\n4. The Actual server has `ACTUAL_GITHUB_TOKEN` with access to `acme/plugin-private`.\n5. Any authenticated Actual user calls:\n\n```text\n/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env\n```\n\n6. `isUrlAllowed()` returns true because `/repos/acme/plugin-private/...` starts with `/repos/acme/plugin`.\n7. The proxy sends the request to GitHub with the server token.\n8. The response is returned to the low-privileged Actual user.\n\n## PoC\n\n### Preconditions\n\n1. `ACTUAL_CORS_PROXY_ENABLED=true`.\n2. `ACTUAL_GITHUB_TOKEN` is configured and can read a private repo.\n3. The official plugin allowlist contains a public repo whose owner and repo name are a prefix of the private target repo.\n4. The attacker has any valid Actual session token.\n\nExample:\n\n- Allowlisted public repo: `https://github.com/acme/plugin`\n- Private target repo: `https://github.com/acme/plugin-private`\n- Private file: `.env`\n\n### Manual Reproduction\n\nStep 1: Request a private repo file through the Actual CORS proxy:\n\n```bash\ncurl -s \"http://TARGET_HOST:5006/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env\" \\\n -H \"X-Actual-Token: LOW_PRIVILEGED_ACTUAL_SESSION\"\n```\n\nVulnerable result:\n\n```json\n{\n \"name\": \".env\",\n \"path\": \".env\",\n \"encoding\": \"base64\",\n \"content\": \"UFJPRF9EQl9QQVNTV09SRD0uLi4=\"\n}\n```\n\nStep 2: Decode the returned `content` field:\n\n```bash\necho \"UFJPRF9EQl9QQVNTV09SRD0uLi4=\" | base64 -d\n```\n\nExample decoded output:\n\n```text\nPROD_DB_PASSWORD=...\n```\n\n### Python PoC\n\nAttached separately as `poc_actual_cors_proxy_github_prefix_bypass.py`.\n\nThe 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.\n\n## Impact\n\n- Any authenticated Actual user can use the server's GitHub token against prefix-matched repositories outside the plugin allowlist.\n- Private source code, repository metadata, release data, deployment files, and accidentally committed secrets can be exposed.\n- 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.\n- Exposed repository secrets or deployment material may enable follow-on compromise of production infrastructure or supply-chain release assets.\n\n## Recommended Remediation\n\n- Parse and compare GitHub API repository path segments exactly.\n- Replace:\n\n```js\nurl.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)\n```\n\nwith an exact boundary-aware check:\n\n```js\nurl.pathname === `/repos/${repoOwner}/${repoName}` ||\nurl.pathname.startsWith(`/repos/${repoOwner}/${repoName}/`)\n```\n\n- Apply the same boundary discipline to every allowlist branch.\n- Add regression tests showing that an allowlisted `owner/plugin` does not authorize `owner/plugin-private`.\n- Consider never attaching `ACTUAL_GITHUB_TOKEN` for user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "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"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "@actual-app/sync-server"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "26.7.0"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 26.6.0"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/actualbudget/actual/security/advisories/GHSA-m62c-5q34-f3cf"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-57449"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/actualbudget/actual"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-200", "CWE-284", "CWE-863" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-10-07T14:00:45Z",
"nvd_published_at" : "2026-09-25T23:16:53Z"
}
}