{"id":"GHSA-m62c-5q34-f3cf","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 =\u003e 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.","aliases":["CVE-2026-57449"],"modified":"2026-10-07T14:16:44.814019296Z","published":"2026-10-07T14:00:45Z","database_specific":{"nvd_published_at":"2026-09-25T23:16:53Z","cwe_ids":["CWE-200","CWE-284","CWE-863"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-07T14:00:45Z"},"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"}],"affected":[{"package":{"name":"@actual-app/sync-server","ecosystem":"npm","purl":"pkg:npm/%40actual-app/sync-server"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"26.7.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 26.6.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-m62c-5q34-f3cf/GHSA-m62c-5q34-f3cf.json"}}],"schema_version":"1.9.0","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"}]}