mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
## What kind of change does this PR introduce? Feature + docs. Stacked on #48161 (logo contract / [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract)). ## What is the current behavior? After #48161, curated logos only resolve from allowlisted `redirect_uri` hosts. A requester can still present a trusted partner **name** (e.g. Claude) while redirecting to an unrelated remote host; the UI shows Supabase alone but does not call out the mismatch. ## What is the new behavior? - Shows a caution admonition when the requester name looks like a trusted partner (Claude, Cursor, ChatGPT/OpenAI, Perplexity) but `redirect_uri` is a **remote** host outside that partner's allowlist. - Skips localhost / loopback redirects for the caution (common for local MCP clients); those still get curated logos when the name matches a trusted partner. - Highlights the footer redirect URL in warning colour when the caution is shown. - Documents the behaviour in the Connect interstitials pattern. ### To test Real MCP clients (Claude, Cursor, etc.) only send users to **production** `/authorize`, so you cannot drive a local or preview Studio build from those tools. Use a Network override instead: 1. Start Studio and sign in (`pnpm dev:studio`, or use the [Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/)). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine; the real response may 404) ([Vercel preview](https://studio-staging-git-danny-oauth-impersonation-warning-supabase.vercel.app/dashboard/authorize?auth_id=foo)). 3. DevTools → **Network** → find `GET …/platform/oauth/authorizations/foo` (or whatever id you used). 4. Right-click → **Override content** (enable Local Overrides / pick a folder if prompted). 5. Paste one of the payloads below (status **200**), save, then reload the authorize page. 6. Keep `expires_at` in the future so the request does not look expired. #### Impersonation caution (trusted name + remote non-allowlisted redirect) Expect: - Supabase alone (no curated Claude mark) - Caution: “Redirect does not match this app name” - Footer redirect URL in warning colour ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Claude Supabase" src="https://github.com/user-attachments/assets/e6eee016-5710-41ba-9925-87511e009e22" /> | #### Localhost MCP: no caution Expect curated Claude + Supabase pair (name match + loopback), **no** caution, normal footer colour. Local MCP clients often use loopback redirects. ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "http://127.0.0.1:42813/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Claude Supabase" src="https://github.com/user-attachments/assets/79f36865-3c8e-43e5-9490-24288efc74aa" /> | #### Legitimate curated partner: no caution Expect curated Cursor + Supabase pair, no admonition, normal footer colour. ```json { "name": "Cursor", "website": "https://cursor.com", "icon": null, "domain": "cursor.com", "redirect_uri": "https://cursor.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="56164" src="https://github.com/user-attachments/assets/412333a3-a74f-42eb-9f63-d56b6a26bf91" /> | #### Unrelated name + remote redirect: no caution Expect Supabase alone (no icon), no admonition. ```json { "name": "Acme Tools", "website": "https://evil.com", "icon": null, "domain": "evil.com", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` | Preview | | --- | | <img width="764" height="958" alt="Authorize Acme Tools Supabase" src="https://github.com/user-attachments/assets/dab24817-5c26-4aa1-a447-796c4af5868b" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added an OAuth caution when a requester name matches a known partner but uses an unapproved remote redirect host. - Improved trusted partner logo selection for localhost/loopback redirects while preserving safe fallbacks for untrusted redirects. - **Documentation** - Updated Connect interstitial guidance for redirect mismatches and localhost/loopback behavior. - **Tests** - Expanded coverage for caution visibility, messaging, localhost logo pairing, and trusted redirect scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder'
|
|
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
findTrustedPartnerByRedirectUri,
|
|
getOAuthImpersonationWarning,
|
|
getRedirectHostname,
|
|
getRequesterLogo,
|
|
hostMatchesAllowlist,
|
|
isLocalRedirectHost,
|
|
} from './OAuthApps.utils'
|
|
|
|
describe('hostMatchesAllowlist', () => {
|
|
test('allows exact and subdomain hosts', () => {
|
|
expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true)
|
|
expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true)
|
|
})
|
|
|
|
test('rejects lookalike hosts', () => {
|
|
expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false)
|
|
expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false)
|
|
expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isLocalRedirectHost', () => {
|
|
test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])(
|
|
'treats %s as local',
|
|
(host) => {
|
|
expect(isLocalRedirectHost(host)).toBe(true)
|
|
}
|
|
)
|
|
|
|
test('treats public hosts as remote', () => {
|
|
expect(isLocalRedirectHost('claude.ai')).toBe(false)
|
|
expect(isLocalRedirectHost('evil.com')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getRedirectHostname', () => {
|
|
test('parses https redirect URIs', () => {
|
|
expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai')
|
|
})
|
|
|
|
test('returns null for invalid URIs', () => {
|
|
expect(getRedirectHostname('not-a-url')).toBe(null)
|
|
expect(getRedirectHostname(null)).toBe(null)
|
|
})
|
|
})
|
|
|
|
describe('findTrustedPartnerByRedirectUri', () => {
|
|
test('resolves Claude from redirect host', () => {
|
|
expect(
|
|
findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName
|
|
).toBe('Claude')
|
|
})
|
|
|
|
test('ignores localhost redirects', () => {
|
|
expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null)
|
|
})
|
|
})
|
|
|
|
describe('getRequesterLogo', () => {
|
|
test('uses curated assets when redirect host is allowlisted', () => {
|
|
const trusted = getRequesterLogo({
|
|
icon: null,
|
|
name: 'Claude',
|
|
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
|
|
useDarkVariant: false,
|
|
})
|
|
expect(trusted).toEqual({
|
|
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
|
|
isKnownClient: true,
|
|
})
|
|
})
|
|
|
|
test('uses curated assets for localhost when the name matches a trusted partner', () => {
|
|
expect(
|
|
getRequesterLogo({
|
|
icon: null,
|
|
name: 'Claude',
|
|
redirectUri: 'http://127.0.0.1:42813/callback',
|
|
useDarkVariant: false,
|
|
})
|
|
).toEqual({
|
|
src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }),
|
|
isKnownClient: true,
|
|
})
|
|
})
|
|
|
|
test('does not use curated assets from name alone on a remote host', () => {
|
|
expect(
|
|
getRequesterLogo({
|
|
icon: null,
|
|
name: 'Claude',
|
|
redirectUri: 'https://evil.com/callback',
|
|
useDarkVariant: false,
|
|
})
|
|
).toEqual({ src: '', isKnownClient: false })
|
|
})
|
|
|
|
test('falls back to the supplied icon URL when redirect is not trusted', () => {
|
|
expect(
|
|
getRequesterLogo({
|
|
icon: 'https://example.com/icon.png',
|
|
name: 'Acme',
|
|
redirectUri: 'https://evil.com/callback',
|
|
useDarkVariant: false,
|
|
})
|
|
).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false })
|
|
})
|
|
})
|
|
|
|
describe('getOAuthImpersonationWarning', () => {
|
|
test('warns when a trusted name redirects to a remote non-allowlisted host', () => {
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Claude Desktop',
|
|
redirectUri: 'https://evil.com/callback',
|
|
})
|
|
).toEqual({
|
|
brandDisplayName: 'Claude',
|
|
redirectHost: 'evil.com',
|
|
})
|
|
})
|
|
|
|
test('skips localhost MCP redirects', () => {
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Claude',
|
|
redirectUri: 'http://127.0.0.1:42813/callback',
|
|
})
|
|
).toBe(null)
|
|
})
|
|
|
|
test('skips when redirect host matches the named partner', () => {
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Claude',
|
|
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
|
|
})
|
|
).toBe(null)
|
|
})
|
|
|
|
test('skips when the name does not match a trusted partner', () => {
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Acme Tools',
|
|
redirectUri: 'https://evil.com/callback',
|
|
})
|
|
).toBe(null)
|
|
})
|
|
|
|
test('skips missing or unparsable redirect URIs', () => {
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Claude',
|
|
redirectUri: null,
|
|
})
|
|
).toBe(null)
|
|
expect(
|
|
getOAuthImpersonationWarning({
|
|
name: 'Claude',
|
|
redirectUri: 'not-a-url',
|
|
})
|
|
).toBe(null)
|
|
})
|
|
})
|