From 9eda826b90b39b7320e70a13ddd1eb99ae3fcdaf Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Feb 2026 17:38:10 -0800 Subject: [PATCH 1/3] feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist --- apps/sim/app/api/mcp/servers/[id]/route.ts | 12 ++++ apps/sim/app/api/mcp/servers/route.ts | 10 +++ .../api/mcp/servers/test-connection/route.ts | 10 +++ .../api/settings/allowed-mcp-domains/route.ts | 21 ++++++ .../settings-modal/components/mcp/mcp.tsx | 50 +++++++++++++-- apps/sim/ee/sso/components/sso-settings.tsx | 50 +++++++-------- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 29 +++++++++ apps/sim/lib/mcp/domain-check.ts | 64 +++++++++++++++++++ apps/sim/lib/mcp/service.ts | 33 ++++++---- helm/sim/values.yaml | 1 + 11 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 apps/sim/app/api/settings/allowed-mcp-domains/route.ts create mode 100644 apps/sim/lib/mcp/domain-check.ts diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index e7b2d9f1d3..be7c30c6f0 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -29,6 +30,17 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( // Remove workspaceId from body to prevent it from being updated const { workspaceId: _, ...updateData } = body + if (updateData.url) { + try { + validateMcpDomain(updateData.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + } + // Get the current server to check if URL is changing const [currentServer] = await db .select({ url: mcpServers.url }) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 8f304035b9..f6bd6b7823 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { @@ -72,6 +73,15 @@ export const POST = withMcpAuth('write')( ) } + try { + validateMcpDomain(body.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID() const [existingServer] = await db diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 69fb034a86..5a5a3f85a5 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { McpClient } from '@/lib/mcp/client' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import type { McpTransport } from '@/lib/mcp/types' @@ -71,6 +72,15 @@ export const POST = withMcpAuth('write')( ) } + try { + validateMcpDomain(body.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + // Build initial config for resolution const initialConfig = { id: `test-${requestId}`, diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts new file mode 100644 index 0000000000..b7f861cdcb --- /dev/null +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' + +export async function GET() { + const configuredDomains = getAllowedMcpDomainsFromEnv() + if (configuredDomains === null) { + return NextResponse.json({ allowedMcpDomains: null }) + } + + try { + const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase() + if (!configuredDomains.includes(platformHostname)) { + return NextResponse.json({ + allowedMcpDomains: [...configuredDomains, platformHostname], + }) + } + } catch {} + + return NextResponse.json({ allowedMcpDomains: configuredDomains }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index 3b1980acda..335e997501 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -106,6 +106,21 @@ interface McpServer { const logger = createLogger('McpSettings') +/** + * Checks if a URL's hostname is in the allowed domains list. + * Returns true if no allowlist is configured (null) or the domain matches. + */ +function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean { + if (allowedDomains === null) return true + if (!url) return true + try { + const hostname = new URL(url).hostname.toLowerCase() + return allowedDomains.includes(hostname) + } catch { + return true + } +} + const DEFAULT_FORM_DATA: McpServerFormData = { name: '', transport: 'streamable-http', @@ -390,6 +405,15 @@ export function MCP({ initialServerId }: MCPProps) { } = useMcpServerTest() const availableEnvVars = useAvailableEnvVarKeys(workspaceId) + const [allowedMcpDomains, setAllowedMcpDomains] = useState(null) + + useEffect(() => { + fetch('/api/settings/allowed-mcp-domains') + .then((res) => res.json()) + .then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null)) + .catch(() => setAllowedMcpDomains(null)) + }, []) + const urlInputRef = useRef(null) const [showAddForm, setShowAddForm] = useState(false) @@ -1006,10 +1030,12 @@ export function MCP({ initialServerId }: MCPProps) { const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 const isFormValid = formData.name.trim() && formData.url?.trim() - const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid + const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains) + const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim() + const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains) const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection) const hasEditChanges = useMemo(() => { if (editFormData.name !== editOriginalData.name) return true @@ -1299,6 +1325,11 @@ export function MCP({ initialServerId }: MCPProps) { onChange={(e) => handleEditInputChange('url', e.target.value)} onScroll={setEditUrlScrollLeft} /> + {isEditDomainBlocked && ( +

+ Domain not permitted by server policy +

+ )}
@@ -1351,7 +1382,7 @@ export function MCP({ initialServerId }: MCPProps) { @@ -1361,7 +1392,9 @@ export function MCP({ initialServerId }: MCPProps) { @@ -1489,7 +1527,9 @@ export function MCP({ initialServerId }: MCPProps) { Cancel
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index a43e15ff37..bb1fa515f9 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' -import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react' +import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react' import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' @@ -418,29 +418,29 @@ export function SSO() { {/* Callback URL */}
- - Callback URL - -
-
- - {providerCallbackUrl} - -
+
+ + Callback URL +
+
+ + {providerCallbackUrl} + +

Configure this in your identity provider

@@ -852,29 +852,29 @@ export function SSO() { {/* Callback URL display */}
- - Callback URL - -
-
- - {callbackUrl} - -
+
+ + Callback URL +
+
+ + {callbackUrl} + +

Configure this in your identity provider

diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b154fbdbbd..8d06f938c1 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -93,6 +93,7 @@ export const env = createEnv({ EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic") BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") + ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9f746c5b12..3ff517fb8d 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -123,6 +123,35 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) */ export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +/** + * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. + * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). + * Extracts the hostname in either case. + */ +function normalizeDomainEntry(entry: string): string { + const trimmed = entry.trim().toLowerCase() + if (!trimmed) return '' + if (trimmed.includes('://')) { + try { + return new URL(trimmed).hostname + } catch { + return trimmed + } + } + return trimmed +} + +/** + * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. + * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. + * Accepts both bare hostnames and full URLs in the env var value. + */ +export function getAllowedMcpDomainsFromEnv(): string[] | null { + if (!env.ALLOWED_MCP_DOMAINS) return null + const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) + return parsed.length > 0 ? parsed : null +} + /** * Get cost multiplier based on environment */ diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts new file mode 100644 index 0000000000..14ef96e5c5 --- /dev/null +++ b/apps/sim/lib/mcp/domain-check.ts @@ -0,0 +1,64 @@ +import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' + +export class McpDomainNotAllowedError extends Error { + constructor(domain: string) { + super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`) + this.name = 'McpDomainNotAllowedError' + } +} + +let cachedPlatformHostname: string | null = null +let platformHostnameResolved = false + +/** + * Returns the platform's own hostname (from getBaseUrl), lazy-cached. + * Always lowercase. Returns null if the base URL is not configured or invalid. + */ +function getPlatformHostname(): string | null { + if (platformHostnameResolved) return cachedPlatformHostname + platformHostnameResolved = true + try { + cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase() + } catch { + cachedPlatformHostname = null + } + return cachedPlatformHostname +} + +/** + * Core domain check. Returns null if the URL is allowed, or the hostname/url + * string to use in the rejection error. + */ +function checkMcpDomain(url: string): string | null { + const allowedDomains = getAllowedMcpDomainsFromEnv() + if (allowedDomains === null) return null + try { + const hostname = new URL(url).hostname.toLowerCase() + if (hostname === getPlatformHostname()) return null + return allowedDomains.includes(hostname) ? null : hostname + } catch { + return url + } +} + +/** + * Returns true if the URL's domain is allowed (or no restriction is configured). + * The platform's own hostname (from getBaseUrl) is always allowed. + */ +export function isMcpDomainAllowed(url: string | undefined): boolean { + if (!url) return true + return checkMcpDomain(url) === null +} + +/** + * Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist. + * The platform's own hostname (from getBaseUrl) is always allowed. + */ +export function validateMcpDomain(url: string | undefined): void { + if (!url) return + const rejected = checkMcpDomain(url) + if (rejected !== null) { + throw new McpDomainNotAllowedError(rejected) + } +} diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index e38cfb3f02..d9bc1aa20f 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -10,6 +10,7 @@ import { isTest } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' +import { isMcpDomainAllowed } from '@/lib/mcp/domain-check' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { createMcpCacheAdapter, @@ -93,6 +94,10 @@ class McpService { return null } + if (!isMcpDomainAllowed(server.url || undefined)) { + return null + } + return { id: server.id, name: server.name, @@ -123,19 +128,21 @@ class McpService { .from(mcpServers) .where(and(...whereConditions)) - return servers.map((server) => ({ - id: server.id, - name: server.name, - description: server.description || undefined, - transport: server.transport as McpTransport, - url: server.url || undefined, - headers: (server.headers as Record) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - })) + return servers + .map((server) => ({ + id: server.id, + name: server.name, + description: server.description || undefined, + transport: server.transport as McpTransport, + url: server.url || undefined, + headers: (server.headers as Record) || {}, + timeout: server.timeout || 30000, + retries: server.retries || 3, + enabled: server.enabled, + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), + })) + .filter((config) => isMcpDomainAllowed(config.url)) } /** diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 9ac47e95e1..dc7b2081ab 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -193,6 +193,7 @@ app: # LLM Provider/Model Restrictions (leave empty if not restricting) BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google") BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") + ALLOWED_MCP_DOMAINS: "" # Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. # Invitation Control DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally From bc7ece0d2495ada17121e492c3a60ea02b3b8bbf Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Feb 2026 17:45:51 -0800 Subject: [PATCH 2/3] ack PR comments --- apps/sim/app/api/settings/allowed-mcp-domains/route.ts | 6 ++++++ .../components/settings-modal/components/mcp/mcp.tsx | 2 +- apps/sim/lib/mcp/domain-check.ts | 6 ++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts index b7f861cdcb..07ec5d1079 100644 --- a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' export async function GET() { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const configuredDomains = getAllowedMcpDomainsFromEnv() if (configuredDomains === null) { return NextResponse.json({ allowedMcpDomains: null }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index 335e997501..b0f2079fd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -117,7 +117,7 @@ function isDomainAllowed(url: string | undefined, allowedDomains: string[] | nul const hostname = new URL(url).hostname.toLowerCase() return allowedDomains.includes(hostname) } catch { - return true + return false } } diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 14ef96e5c5..2689b09807 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -9,19 +9,17 @@ export class McpDomainNotAllowedError extends Error { } let cachedPlatformHostname: string | null = null -let platformHostnameResolved = false /** * Returns the platform's own hostname (from getBaseUrl), lazy-cached. * Always lowercase. Returns null if the base URL is not configured or invalid. */ function getPlatformHostname(): string | null { - if (platformHostnameResolved) return cachedPlatformHostname - platformHostnameResolved = true + if (cachedPlatformHostname !== null) return cachedPlatformHostname try { cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase() } catch { - cachedPlatformHostname = null + return null } return cachedPlatformHostname } From a65da5e5c840e342c26d7daf3da814e0c1c7653a Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Feb 2026 17:59:13 -0800 Subject: [PATCH 3/3] cleanup --- apps/sim/lib/mcp/domain-check.test.ts | 163 ++++++++++++++++++++++++++ apps/sim/lib/mcp/domain-check.ts | 11 +- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/mcp/domain-check.test.ts diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts new file mode 100644 index 0000000000..ff72f7e6f6 --- /dev/null +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetAllowedMcpDomainsFromEnv = vi.fn<() => string[] | null>() +const mockGetBaseUrl = vi.fn<() => string>() + +vi.doMock('@/lib/core/config/feature-flags', () => ({ + getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, +})) + +vi.doMock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mockGetBaseUrl, +})) + +const { McpDomainNotAllowedError, isMcpDomainAllowed, validateMcpDomain } = await import( + './domain-check' +) + +describe('McpDomainNotAllowedError', () => { + it.concurrent('creates error with correct name and message', () => { + const error = new McpDomainNotAllowedError('evil.com') + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(McpDomainNotAllowedError) + expect(error.name).toBe('McpDomainNotAllowedError') + expect(error.message).toContain('evil.com') + }) +}) + +describe('isMcpDomainAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when no allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null) + }) + + it('allows any URL', () => { + expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true) + }) + + it('allows undefined URL', () => { + expect(isMcpDomainAllowed(undefined)).toBe(true) + }) + + it('allows empty string URL', () => { + expect(isMcpDomainAllowed('')).toBe(true) + }) + }) + + describe('when allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com']) + mockGetBaseUrl.mockReturnValue('https://platform.example.com') + }) + + it('allows URLs on the allowlist', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true) + }) + + it('rejects URLs not on the allowlist', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + }) + + it('rejects undefined URL (fail-closed)', () => { + expect(isMcpDomainAllowed(undefined)).toBe(false) + }) + + it('rejects empty string URL (fail-closed)', () => { + expect(isMcpDomainAllowed('')).toBe(false) + }) + + it('rejects malformed URLs', () => { + expect(isMcpDomainAllowed('not-a-url')).toBe(false) + }) + + it('matches case-insensitively', () => { + expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true) + }) + + it('always allows the platform hostname', () => { + expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) + }) + + it('allows platform hostname even when not in the allowlist', () => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['other.com']) + expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) + }) + }) + + describe('when getBaseUrl is not configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) + mockGetBaseUrl.mockImplementation(() => { + throw new Error('Not configured') + }) + }) + + it('still allows URLs on the allowlist', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + }) + + it('still rejects URLs not on the allowlist', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + }) + }) +}) + +describe('validateMcpDomain', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when no allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null) + }) + + it('does not throw for any URL', () => { + expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow() + }) + + it('does not throw for undefined URL', () => { + expect(() => validateMcpDomain(undefined)).not.toThrow() + }) + }) + + describe('when allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) + mockGetBaseUrl.mockReturnValue('https://platform.example.com') + }) + + it('does not throw for allowed URLs', () => { + expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow() + }) + + it('throws McpDomainNotAllowedError for disallowed URLs', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError) + }) + + it('throws for undefined URL (fail-closed)', () => { + expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError) + }) + + it('throws for malformed URLs', () => { + expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError) + }) + + it('includes the rejected domain in the error message', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/) + }) + + it('does not throw for platform hostname', () => { + expect(() => validateMcpDomain('https://platform.example.com/mcp')).not.toThrow() + }) + }) +}) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 2689b09807..e4087031fd 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -45,7 +45,9 @@ function checkMcpDomain(url: string): string | null { * The platform's own hostname (from getBaseUrl) is always allowed. */ export function isMcpDomainAllowed(url: string | undefined): boolean { - if (!url) return true + if (!url) { + return getAllowedMcpDomainsFromEnv() === null + } return checkMcpDomain(url) === null } @@ -54,7 +56,12 @@ export function isMcpDomainAllowed(url: string | undefined): boolean { * The platform's own hostname (from getBaseUrl) is always allowed. */ export function validateMcpDomain(url: string | undefined): void { - if (!url) return + if (!url) { + if (getAllowedMcpDomainsFromEnv() !== null) { + throw new McpDomainNotAllowedError('(empty)') + } + return + } const rejected = checkMcpDomain(url) if (rejected !== null) { throw new McpDomainNotAllowedError(rejected)