Vulnerabilities in pkg:npm/@actual-app/sync-server

PACKAGE
Advisory Identifier: GHSA-m62c-5q34-f3cf
Advisory Summary

CVE-2026-57449 is a HIGH cors proxy github api allowlist vulnerability (CWE-284: Improper Access Control) affecting Actual Sync Server libraries in the npm ecosystem. The issue involves cors proxy github api allowlist prefix bypass leaks private repositories through the server github token. Remediation is available by upgrading affected packages to version 26.7.0.

8 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:npm/@actual-app/sync-server

8 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:npm/@actual-app/sync-server. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-m62c-5q34-f3cf CVE-2026-57449 HIGH Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token
GHSA-cq9c-6w48-qmfg CVE-2026-49229 HIGH @actual-app/sync-server: Disabled OpenID users keep access through existing session tokens
GHSA-3f62-qv96-4p78 CVE-2026-46700 MODERATE @actual-app/sync-server's missing authorization on GET /secret/:name allows non-admin OpenID users to enumerate admin-configured bank-sync secrets
GHSA-xvp7-8vm8-xfxx - MODERATE Actual Sync-server Gocardless service is logging sensitive data including bearer tokens and account numbers
GHSA-prp4-2f49-fcgp CVE-2026-33318 HIGH Actual has Privilege Escalation via 'change-password' Endpoint on OpenID-Migrated Servers
GHSA-27vg-33gh-4hwg CVE-2026-3089 MODERATE Actual Sync Server has an Authenticated Path Traversal
GHSA-qmjj-p7m9-wjrv CVE-2026-27638 MODERATE @actual-app/sync-server: Missing authorization in sync endpoints allows cross-user budget file access in multi-user mode
GHSA-m2cq-xjgm-f668 CVE-2026-27584 CRITICAL ActualBudget server is Missing Authentication for SimpleFIN and Pluggy AI bank sync endpoints

CVE-2026-57449 at a Glance

CVE Identifier CVE-2026-57449
GHSA Identifier GHSA-m62c-5q34-f3cf
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-284: Improper Access Control
Affected Product Actual Sync Server
Package Ecosystem npm
Published Date
Last Updated

How to Fix CVE-2026-57449

To remediate this vulnerability, upgrade all affected Actual Sync Server dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@actual-app/sync-server fixed: 26.7.0
npm install @actual-app/sync-server@26.7.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @actual-app/sync-server

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-57449, inspect your project dependencies using npm:

npm ls @actual-app/sync-server

Analyze the terminal output to locate any version of Actual Sync Server in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 26.7.0), your application is affected.

Note: The npm ls @actual-app/sync-server command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-284: Improper Access Control, where the product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. In Actual Sync Server, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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: ```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 `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: ```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 `ACTUAL_CORS_PROXY_ENABLED`. The GitHub token is configured through `ACTUAL_GITHUB_TOKEN`: ```js 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: ```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 `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: ```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. `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: ```bash 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: ```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 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: ```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 `ACTUAL_GITHUB_TOKEN` for user-driven proxy requests unless the requested repo exactly matches an allowlisted repository.

CVE-2026-57449 Affected Actual Sync Server Versions

1 component(s)
Fixed Versions:
26.7.0
Product / Package Ecosystem Affected Versions Fixed Version
@actual-app/sync-server
npm
< 26.7.0
26.7.0

References

Frequently Asked Questions

What is CVE-2026-57449?
CVE-2026-57449 is a security vulnerability in Actual Sync Server involving CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token under CWE-284: Improper Access Control.
Which Actual Sync Server versions are affected?
Affected versions include releases prior to 26.7.0 across affected packages: @actual-app/sync-server.
What version fixes CVE-2026-57449?
Upgrading to 26.7.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @actual-app/sync-server affected?
Yes, @actual-app/sync-server is affected in releases prior to 26.7.0. Upgrade your dependency to 26.7.0 or later.
What CWE is associated with CVE-2026-57449?
CVE-2026-57449 is classified under CWE-284: Improper Access Control (The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.).
How can I check npm dependencies for CVE-2026-57449?
Run the dependency tree command: 'npm ls @actual-app/sync-server'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-m62c-5q34-f3cf",
  "modified" : "2026-10-07T14:00:45Z",
  "published" : "2026-10-07T14:00:45Z",
  "aliases" : [ "CVE-2026-57449" ],
  "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.",
  "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@actual-app/sync-server"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "26.7.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 26.6.0"
    }
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-200", "CWE-284", "CWE-863" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-07T14:00:45Z",
    "nvd_published_at" : "2026-09-25T23:16:53Z"
  }
}