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
@@ -41,6 +41,8 @@ import { GET, POST } from '../route'
|
||||
const CLAUDE: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'claude' }
|
||||
const CHATGPT: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'chatgpt' }
|
||||
const GROK: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'grok' }
|
||||
const CURSOR: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'cursor' }
|
||||
const CURSOR_DEEPLINK: RedirectUriResolution = { allowed: true, kind: 'built_in', provider: 'cursor_deeplink' }
|
||||
const REGISTERED: RedirectUriResolution = {
|
||||
allowed: true,
|
||||
kind: 'registered',
|
||||
@@ -384,6 +386,65 @@ describe('client identity on the consent page', () => {
|
||||
expect(html).not.toContain('En extern applikation')
|
||||
})
|
||||
|
||||
it('names Cursor as a verified client for the cursor.com callback', async () => {
|
||||
mocks.resolveRedirectUri.mockResolvedValue(CURSOR)
|
||||
const html = await (
|
||||
await GET(
|
||||
new Request(
|
||||
buildAuthorizeUrl({
|
||||
...params,
|
||||
redirect_uri: 'https://www.cursor.com/agents/mcp/oauth/callback',
|
||||
}),
|
||||
),
|
||||
)
|
||||
).text()
|
||||
|
||||
expect(html).toContain('Cursor (Anysphere)')
|
||||
expect(html).toContain('Verifierad')
|
||||
expect(html).toContain('www.cursor.com')
|
||||
expect(html).not.toContain('En extern applikation')
|
||||
})
|
||||
|
||||
it('shows the cursor:// deeplink as Cursor but unverified, like localhost', async () => {
|
||||
// Any local app can claim a custom scheme (RFC 8252 section 8.4), so the
|
||||
// page must not present it as a vendor-verified callback.
|
||||
mocks.resolveRedirectUri.mockResolvedValue(CURSOR_DEEPLINK)
|
||||
const html = await (
|
||||
await GET(
|
||||
new Request(
|
||||
buildAuthorizeUrl({
|
||||
...params,
|
||||
redirect_uri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
||||
}),
|
||||
),
|
||||
)
|
||||
).text()
|
||||
|
||||
expect(html).toContain('Cursor (Anysphere)')
|
||||
expect(html).toContain('Din egen dator')
|
||||
expect(html).not.toContain('Verifierad')
|
||||
expect(html).not.toContain('En extern applikation')
|
||||
})
|
||||
|
||||
it('form-action uses a scheme-source for a custom-scheme redirect_uri', async () => {
|
||||
// new URL('cursor://...').origin is the string "null", which CSP reads as
|
||||
// a host named "null": with that the post-consent 303 to the deeplink is
|
||||
// blocked in Chromium. The scheme-source form (cursor:) lets it through.
|
||||
mocks.resolveRedirectUri.mockResolvedValue(CURSOR_DEEPLINK)
|
||||
const response = await GET(
|
||||
new Request(
|
||||
buildAuthorizeUrl({
|
||||
...params,
|
||||
redirect_uri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const csp = response.headers.get('Content-Security-Policy')
|
||||
expect(csp).toMatch(/form-action 'self' cursor:(;|$)/)
|
||||
expect(csp).not.toContain('null')
|
||||
})
|
||||
|
||||
it('shows client_name and redirect host for a DB-registered client, never marked verified', async () => {
|
||||
mocks.resolveRedirectUri.mockResolvedValue(REGISTERED)
|
||||
const html = await (
|
||||
|
||||
@@ -828,12 +828,18 @@ export async function GET(request: Request) {
|
||||
// form-action re-checks every hop in the redirect chain. With only 'self'
|
||||
// the browser would block the post-consent redirect. The origin is safe
|
||||
// to whitelist here because resolveRedirectUri() already gated it above.
|
||||
const redirectOrigin = new URL(redirectUri).origin
|
||||
//
|
||||
// A custom-scheme callback (cursor://...) has the opaque origin "null",
|
||||
// which CSP would read as a host literally named "null" and match nothing,
|
||||
// so the post-consent 303 would be blocked in Chromium. A scheme-source
|
||||
// (cursor:) is the only form CSP offers for such a URI.
|
||||
const redirectUrl = new URL(redirectUri)
|
||||
const formActionSource = redirectUrl.origin === 'null' ? redirectUrl.protocol : redirectUrl.origin
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`script-src 'nonce-${cspNonce}'`,
|
||||
"style-src 'unsafe-inline'",
|
||||
`form-action 'self' ${redirectOrigin}`,
|
||||
`form-action 'self' ${formActionSource}`,
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ')
|
||||
@@ -1094,6 +1100,13 @@ function describeClient(
|
||||
return { name: 'ChatGPT (OpenAI)', tag: 'Verifierad', verified: true }
|
||||
case 'grok':
|
||||
return { name: 'Grok (xAI)', tag: 'Verifierad', verified: true }
|
||||
case 'cursor':
|
||||
return { name: 'Cursor (Anysphere)', tag: 'Verifierad', verified: true }
|
||||
case 'cursor_deeplink':
|
||||
// A custom scheme can be claimed by any local app (RFC 8252 section
|
||||
// 8.4), so it carries loopback trust, not vendor trust: same tag as
|
||||
// localhost, never marked verified.
|
||||
return { name: 'Cursor (Anysphere)', tag: 'Din egen dator', verified: false }
|
||||
case 'local':
|
||||
return { name: 'Lokal utveckling (localhost)', tag: 'Din egen dator', verified: false }
|
||||
}
|
||||
|
||||
@@ -63,6 +63,35 @@ describe('POST /api/mcp-oauth/register', () => {
|
||||
expect(body.token_endpoint_auth_method).toBe('none')
|
||||
})
|
||||
|
||||
it('accepts the three redirect_uris Cursor registers in one request', async () => {
|
||||
// Cursor sends the legacy deeplink, the Cloud Agents web fallback and the
|
||||
// RFC 8252 loopback together; one unknown URI used to fail the whole set.
|
||||
const uris = [
|
||||
'cursor://anysphere.cursor-mcp/oauth/callback',
|
||||
'https://www.cursor.com/agents/mcp/oauth/callback',
|
||||
'http://localhost:8787/callback',
|
||||
]
|
||||
const response = await POST(createRequest({
|
||||
client_name: 'Cursor',
|
||||
redirect_uris: uris,
|
||||
token_endpoint_auth_method: 'none',
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
const body = await response.json()
|
||||
expect(body.redirect_uris).toEqual(uris)
|
||||
})
|
||||
|
||||
it('rejects other cursor.com paths and other cursor:// authorities', async () => {
|
||||
for (const uri of [
|
||||
'https://www.cursor.com/agents/mcp/oauth/callback2',
|
||||
'https://cursor.com/agents/mcp/oauth/callback',
|
||||
'cursor://evil.extension/oauth/callback',
|
||||
]) {
|
||||
const response = await POST(createRequest({ redirect_uris: [uri] }))
|
||||
expect(response.status, uri).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects other grok.com paths', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: ['https://grok.com/oauth/callback'],
|
||||
|
||||
Reference in New Issue
Block a user