fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)
* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs in one /register request: cursor://anysphere.cursor-mcp/oauth/callback, https://www.cursor.com/agents/mcp/oauth/callback and http://localhost:8787/callback. Only the loopback matched a built-in pattern and /register fails the whole set on any unknown URI, so every Cursor connection to the URL we hand out in Settings died with "Redirect URI not allowed". Users cannot self-register the cursor:// form either (the settings panel requires https). Add a built-in `cursor` provider with the two non-loopback callbacks as exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on the consent page, list the pre-approved clients in the OAuth clients settings text (sv + en) and the mcp-server rules, and cover the register, allowlist and consent paths with tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff * fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect Review findings on #2225, one pass: - Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the string "null", so the consent page emitted form-action 'self' null and Chromium would block the 303 to the deeplink after Allow. The header now uses the scheme-source (cursor:) when the origin is opaque; a test pins the header on the cursor:// URI. - Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it must not be presented as a vendor-verified callback. The deeplink is its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with the localhost tag "Din egen dator" and verified: false. The https cursor.com callback keeps the verified label. A test pins that a code minted without a code_challenge can never be exchanged, which is what keeps a scheme hijack from turning into a token. - CodeRabbit (Minor): the rules doc now says the Grok callback matches with or without the trailing slash. - Regression skeptic: docs/WHITELABEL.md listed only Claude and localhost and pointed at the wrong file; now lists the built-ins and points at lib/auth/oauth-allowlist.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d900fea1a8
commit
bc5da12372
@@ -26,6 +26,18 @@ describe('isBuiltInRedirectUri', () => {
|
||||
['https://grok.com/other/path', false],
|
||||
['https://grok.com.evil.com/connectors-oauth-exchange-code/', false],
|
||||
['http://grok.com/connectors-oauth-exchange-code/', false],
|
||||
['cursor://anysphere.cursor-mcp/oauth/callback', true],
|
||||
['cursor://anysphere.cursor-mcp/oauth/callback/', false],
|
||||
['cursor://anysphere.cursor-mcp/oauth/callback?x=1', false],
|
||||
['cursor://anysphere.cursor-mcp/oauth/callback/extra', false],
|
||||
['cursor://evil.extension/oauth/callback', false],
|
||||
['https://www.cursor.com/agents/mcp/oauth/callback', true],
|
||||
['https://www.cursor.com/agents/mcp/oauth/callback/', false],
|
||||
['https://www.cursor.com/agents/mcp/oauth/callback2', false],
|
||||
['https://cursor.com/agents/mcp/oauth/callback', false],
|
||||
['https://www.cursor.com.evil.com/agents/mcp/oauth/callback', false],
|
||||
['http://www.cursor.com/agents/mcp/oauth/callback', false],
|
||||
['http://localhost:8787/callback', true],
|
||||
['http://localhost:3000/cb', true],
|
||||
['http://localhost/cb', true],
|
||||
['http://127.0.0.1:8080/cb', true],
|
||||
@@ -46,6 +58,9 @@ describe('builtInRedirectProvider', () => {
|
||||
['https://chatgpt.com/connector_platform_oauth_redirect', 'chatgpt'],
|
||||
['https://grok.com/connectors-oauth-exchange-code/', 'grok'],
|
||||
['https://grok.com/connectors-oauth-exchange-code/extra', null],
|
||||
['cursor://anysphere.cursor-mcp/oauth/callback', 'cursor_deeplink'],
|
||||
['https://www.cursor.com/agents/mcp/oauth/callback', 'cursor'],
|
||||
['http://localhost:8787/callback', 'local'],
|
||||
['http://localhost:3000/cb', 'local'],
|
||||
['http://127.0.0.1:8080/cb', 'local'],
|
||||
['https://claude-login.example/cb', null],
|
||||
|
||||
@@ -133,3 +133,13 @@ describe('hashAuthCode', () => {
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyPkce with a missing challenge', () => {
|
||||
it('never verifies when the code was minted without a code_challenge', () => {
|
||||
// /authorize does not reject a missing code_challenge; it mints the code
|
||||
// with an empty one. That must stay unexchangeable, otherwise a
|
||||
// custom-scheme (cursor://) hijacker could skip PKCE entirely.
|
||||
expect(verifyPkce('any-verifier', '')).toBe(false)
|
||||
expect(verifyPkce('', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,13 @@ import { scopeKind, type ApiKeyScope } from './scope-catalog'
|
||||
/**
|
||||
* Identity of a built-in client, derived from the redirect URI pattern that
|
||||
* matched. Rendered on the consent page so the user can tell a real Claude /
|
||||
* ChatGPT / Grok connector from a look-alike registration.
|
||||
* ChatGPT / Grok / Cursor connector from a look-alike registration.
|
||||
*/
|
||||
export type BuiltInProvider = 'claude' | 'chatgpt' | 'grok' | 'local'
|
||||
export type BuiltInProvider = 'claude' | 'chatgpt' | 'grok' | 'cursor' | 'cursor_deeplink' | 'local'
|
||||
|
||||
/**
|
||||
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
|
||||
* the Claude, ChatGPT and Grok connectors keep working without seeded rows, and so
|
||||
* the Claude, ChatGPT, Grok and Cursor connectors keep working without seeded rows, and so
|
||||
* local development never depends on having a registration.
|
||||
*
|
||||
* ChatGPT uses a per-connector-instance callback path
|
||||
@@ -36,6 +36,23 @@ export type BuiltInProvider = 'claude' | 'chatgpt' | 'grok' | 'local'
|
||||
* itself: the slash form 308s to the no-slash form on the same origin, so
|
||||
* both are accepted. Matched as an exact path, never a prefix, so a future
|
||||
* grok.com path cannot ride on this entry.
|
||||
*
|
||||
* Cursor (IDE and CLI) registers through /register with three redirect URIs
|
||||
* in one request: the legacy custom-scheme deeplink
|
||||
* cursor://anysphere.cursor-mcp/oauth/callback, the web fallback used by
|
||||
* Cloud Agents and Automations https://www.cursor.com/agents/mcp/oauth/callback,
|
||||
* and the RFC 8252 loopback http://localhost:8787/callback that current
|
||||
* builds actually redirect to (Cursor staff statement, forum.cursor.com
|
||||
* thread 165019). /register rejects the whole set when any one URI is
|
||||
* unknown, so the two non-loopback callbacks are allowlisted here as exact
|
||||
* matches; the loopback already passes through the local rule. The custom
|
||||
* scheme is accepted despite RFC 8252 section 8.4 (any local app can claim a
|
||||
* scheme) because the code is PKCE-bound to the client that started the flow
|
||||
* (S256 only, verifier required at /token, a code minted without a challenge
|
||||
* can never be exchanged) and the loopback form carries the same
|
||||
* local-machine trust. It is its own provider, `cursor_deeplink`, so the
|
||||
* consent page can show it unverified like localhost: a scheme proves
|
||||
* nothing about who receives the code, an https host does.
|
||||
*/
|
||||
const BUILT_IN_PATTERNS: readonly { pattern: RegExp; provider: BuiltInProvider }[] = [
|
||||
{ pattern: /^https:\/\/claude\.ai\/api\//, provider: 'claude' },
|
||||
@@ -43,6 +60,8 @@ const BUILT_IN_PATTERNS: readonly { pattern: RegExp; provider: BuiltInProvider }
|
||||
{ pattern: /^https:\/\/chatgpt\.com\/connector\/oauth\//, provider: 'chatgpt' },
|
||||
{ pattern: /^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/, provider: 'chatgpt' },
|
||||
{ pattern: /^https:\/\/grok\.com\/connectors-oauth-exchange-code\/?$/, provider: 'grok' },
|
||||
{ pattern: /^cursor:\/\/anysphere\.cursor-mcp\/oauth\/callback$/, provider: 'cursor_deeplink' },
|
||||
{ pattern: /^https:\/\/www\.cursor\.com\/agents\/mcp\/oauth\/callback$/, provider: 'cursor' },
|
||||
{ pattern: /^http:\/\/localhost(:\d+)?(\/|$)/, provider: 'local' },
|
||||
{ pattern: /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, provider: 'local' },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user