Skip to content

Commit f75f55a

Browse files
authored
fix(mcp): always guard the MCP transport and validate on every managed connect (#7802)
McpClient no longer falls back to the global fetch when no validated address is supplied; it always uses the SSRF-guarded transport, pinning only a validated private address. McpConnectionManager.connect validates the destination itself on every dial, reconnects included, instead of trusting a caller-supplied address.
1 parent ad11808 commit f75f55a

6 files changed

Lines changed: 150 additions & 21 deletions

File tree

apps/sim/lib/mcp/client.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock('@sim/logger', () => ({
2222

2323
vi.mock('@/lib/mcp/pinned-fetch', () => ({
2424
createGuardedMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })),
25+
createPinnedPrivateMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })),
2526
}))
2627

2728
/**
@@ -72,6 +73,7 @@ vi.mock('@/lib/core/execution-limits', () => ({
7273
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
7374
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
7475
import { McpClient } from '@/lib/mcp/client'
76+
import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch'
7577
import {
7678
type McpClientOptions,
7779
McpOauthAuthorizationRequiredError,
@@ -372,6 +374,38 @@ describe('McpClient notification handler', () => {
372374
expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(secret)
373375
})
374376

377+
it('keeps the transport on the SSRF guard when no validated address is supplied', () => {
378+
new McpClient({
379+
config: createConfig(),
380+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
381+
})
382+
383+
const guarded = vi.mocked(createGuardedMcpFetch).mock.results.at(-1)?.value
384+
expect(createGuardedMcpFetch).toHaveBeenCalledWith('https://test.example.com/mcp')
385+
expect(createPinnedPrivateMcpFetch).not.toHaveBeenCalled()
386+
expect(vi.mocked(StreamableHTTPClientTransport).mock.calls.at(-1)?.[1]?.fetch).toBe(
387+
guarded.fetch
388+
)
389+
})
390+
391+
it('pins the transport to a validated private address', () => {
392+
new McpClient({
393+
config: createConfig(),
394+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
395+
resolvedIP: '10.0.0.5',
396+
})
397+
398+
const pinned = vi.mocked(createPinnedPrivateMcpFetch).mock.results.at(-1)?.value
399+
expect(createPinnedPrivateMcpFetch).toHaveBeenCalledWith(
400+
'10.0.0.5',
401+
'https://test.example.com/mcp'
402+
)
403+
expect(createGuardedMcpFetch).not.toHaveBeenCalled()
404+
expect(vi.mocked(StreamableHTTPClientTransport).mock.calls.at(-1)?.[1]?.fetch).toBe(
405+
pinned.fetch
406+
)
407+
})
408+
375409
it('closes the pinned transport Agent when connect fails', async () => {
376410
mockSdkConnect.mockRejectedValueOnce(new Error('connect boom'))
377411
const client = new McpClient({

apps/sim/lib/mcp/client.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,19 +119,18 @@ export class McpClient {
119119
throw new McpError('OAuth MCP server must use one authentication strategy')
120120
}
121121
const useOauth = this.config.authType === 'oauth'
122-
// `resolvedIP` is null only when the hostname still carries an unresolved env-var
123-
// reference, which is checked again once it resolves. Otherwise the guard validates
124-
// addresses per-connect. A private/loopback resolvedIP only reaches here on a
125-
// self-hosted deployment whose policy permits it, and that case pins to the address
126-
// that was validated rather than to whatever the name resolves to next.
127-
const guarded = resolvedIP
128-
? isPrivateIp(resolvedIP)
122+
// The transport never runs on the global fetch: the guard validates addresses
123+
// per-connect and redirects per-hop whether or not a caller validated the URL
124+
// first. A private/loopback resolvedIP only reaches here on a self-hosted
125+
// deployment whose policy permits it, and that case pins to the address that
126+
// was validated rather than to whatever the name resolves to next.
127+
const guarded =
128+
resolvedIP && isPrivateIp(resolvedIP)
129129
? createPinnedPrivateMcpFetch(resolvedIP, this.config.url)
130130
: createGuardedMcpFetch(this.config.url)
131-
: undefined
132-
this.closeGuardedTransport = guarded?.close
131+
this.closeGuardedTransport = guarded.close
133132
const oauthFetch = useOauth
134-
? createMcpEndpointFetch(guarded?.fetch ?? fetch, {
133+
? createMcpEndpointFetch(guarded.fetch, {
135134
serverUrl: this.config.url,
136135
headers: this.config.headers,
137136
})
@@ -142,11 +141,11 @@ export class McpClient {
142141
serverUrl: this.config.url,
143142
fetch: oauthFetch,
144143
})
145-
: (oauthFetch ?? guarded?.fetch)
144+
: (oauthFetch ?? guarded.fetch)
146145
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
147146
authProvider: useOauth ? this.authProvider : undefined,
148147
...(useOauth ? {} : { requestInit: { headers: this.config.headers } }),
149-
...(transportFetch ? { fetch: transportFetch } : {}),
148+
fetch: transportFetch,
150149
})
151150

152151
this.client = new Client(

apps/sim/lib/mcp/connection-manager.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ const {
3434
mockOnToolsChanged,
3535
mockPublishToolsChanged,
3636
mockGetOrCreateOauthRow,
37+
mockValidateMcpDomain,
38+
mockValidateMcpServerSsrf,
3739
} = vi.hoisted(() => ({
3840
MockMcpClientConstructor: vi.fn(),
41+
mockValidateMcpDomain: vi.fn(),
42+
mockValidateMcpServerSsrf: vi.fn(),
3943
mockOnToolsChanged: vi.fn(() => vi.fn()),
4044
mockPublishToolsChanged: vi.fn(),
4145
mockGetOrCreateOauthRow: vi.fn(),
@@ -50,6 +54,10 @@ vi.mock('@/lib/mcp/pubsub', () => ({
5054
vi.mock('@/lib/mcp/client', () => ({
5155
McpClient: MockMcpClientConstructor,
5256
}))
57+
vi.mock('@/lib/mcp/domain-check', () => ({
58+
validateMcpDomain: mockValidateMcpDomain,
59+
validateMcpServerSsrf: mockValidateMcpServerSsrf,
60+
}))
5361
vi.mock('@/lib/mcp/oauth', () => ({
5462
getOrCreateOauthRow: mockGetOrCreateOauthRow,
5563
loadPreregisteredClient: vi.fn(),
@@ -76,6 +84,7 @@ describe('McpConnectionManager', () => {
7684

7785
beforeEach(() => {
7886
vi.clearAllMocks()
87+
mockValidateMcpServerSsrf.mockResolvedValue('93.184.216.34')
7988
mockGetOrCreateOauthRow.mockResolvedValue({
8089
id: 'oauth-row-1',
8190
mcpServerId: 'server-oauth',
@@ -277,6 +286,82 @@ describe('McpConnectionManager', () => {
277286
})
278287
})
279288

289+
describe('destination validation', () => {
290+
function mockClients(closeHandlers: Array<() => void> = []): MockMcpClient[] {
291+
const instances: MockMcpClient[] = []
292+
MockMcpClientConstructor.mockImplementation(
293+
class {
294+
constructor() {
295+
const instance: MockMcpClient = {
296+
connect: vi.fn().mockResolvedValue(undefined),
297+
disconnect: vi.fn().mockResolvedValue(undefined),
298+
hasListChangedCapability: vi.fn().mockReturnValue(true),
299+
onClose: vi.fn().mockImplementation((handler: () => void) => {
300+
closeHandlers.push(handler)
301+
}),
302+
}
303+
instances.push(instance)
304+
Object.assign(this, instance)
305+
}
306+
}
307+
)
308+
return instances
309+
}
310+
311+
it('validates the destination and hands the resolved address to the client', async () => {
312+
mockClients()
313+
const mgr = createFreshManager()
314+
const config = serverConfig('server-validate')
315+
316+
await mgr.connect(config, 'user-1', 'ws-1')
317+
318+
expect(mockValidateMcpDomain).toHaveBeenCalledWith(config.url)
319+
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(config.url)
320+
const options: McpClientOptions = MockMcpClientConstructor.mock.calls[0][0]
321+
expect(options.resolvedIP).toBe('93.184.216.34')
322+
})
323+
324+
it('never constructs a client for a refused destination and releases the connecting slot', async () => {
325+
const instances = mockClients()
326+
mockValidateMcpServerSsrf.mockRejectedValueOnce(new Error('refused by egress policy'))
327+
const mgr = createFreshManager()
328+
const config = serverConfig('server-refused')
329+
330+
await expect(mgr.connect(config, 'user-1', 'ws-1')).rejects.toThrow(
331+
'refused by egress policy'
332+
)
333+
expect(instances).toHaveLength(0)
334+
335+
const retry = await mgr.connect(config, 'user-1', 'ws-1')
336+
expect(retry.supportsListChanged).toBe(true)
337+
expect(instances).toHaveLength(1)
338+
})
339+
340+
it('re-validates the destination before every reconnect', async () => {
341+
vi.useFakeTimers()
342+
const closeHandlers: Array<() => void> = []
343+
const instances = mockClients(closeHandlers)
344+
const mgr = createFreshManager()
345+
const config = serverConfig('server-reconnect')
346+
347+
await mgr.connect(config, 'user-1', 'ws-1')
348+
mockValidateMcpServerSsrf.mockRejectedValueOnce(new Error('refused by egress policy'))
349+
mockValidateMcpServerSsrf.mockResolvedValueOnce('93.184.216.35')
350+
351+
closeHandlers[0]()
352+
await vi.advanceTimersByTimeAsync(2_000)
353+
expect(mockValidateMcpServerSsrf).toHaveBeenCalledTimes(2)
354+
expect(instances).toHaveLength(1)
355+
356+
await vi.advanceTimersByTimeAsync(5_000)
357+
expect(mockValidateMcpServerSsrf).toHaveBeenCalledTimes(3)
358+
expect(instances).toHaveLength(2)
359+
const options: McpClientOptions = MockMcpClientConstructor.mock.calls[1][0]
360+
expect(options.resolvedIP).toBe('93.184.216.35')
361+
expect(mgr.hasConnection('server-reconnect')).toBe(true)
362+
})
363+
})
364+
280365
describe('dispose', () => {
281366
it('rejects new connections after dispose', async () => {
282367
MockMcpClientConstructor.mockImplementation(

apps/sim/lib/mcp/connection-manager.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createLogger } from '@sim/logger'
1414
import { backoffWithJitter } from '@sim/utils/retry'
1515
import { isTest } from '@/lib/core/config/env-flags'
1616
import { McpClient } from '@/lib/mcp/client'
17+
import { validateMcpDomain, validateMcpServerSsrf } from '@/lib/mcp/domain-check'
1718
import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth'
1819
import { mcpPubSub } from '@/lib/mcp/pubsub'
1920
import {
@@ -106,12 +107,19 @@ export class McpConnectionManager {
106107
*
107108
* If the server does NOT support `listChanged`, the client is disconnected
108109
* immediately — there's nothing to listen for.
110+
*
111+
* `config` must already have its env-var references resolved. The destination
112+
* is validated here on every dial, including reconnects, so the address is
113+
* never carried over from an earlier resolution.
114+
*
115+
* @throws McpDomainNotAllowedError when the domain is not allowlisted
116+
* @throws McpSsrfError when the egress policy refuses the destination
117+
* @throws McpDnsResolutionError when the hostname cannot be resolved
109118
*/
110119
async connect(
111120
config: McpServerConfig,
112121
userId: string,
113-
workspaceId: string,
114-
resolvedIP?: string | null
122+
workspaceId: string
115123
): Promise<{ supportsListChanged: boolean }> {
116124
if (this.disposed) {
117125
logger.warn('Connection manager is disposed, ignoring connect request')
@@ -172,6 +180,9 @@ export class McpConnectionManager {
172180
}
173181
}
174182

183+
validateMcpDomain(config.url)
184+
const resolvedIP = await validateMcpServerSsrf(config.url)
185+
175186
const client = new McpClient({
176187
config,
177188
securityPolicy: {

apps/sim/lib/mcp/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,12 +1095,12 @@ class McpService {
10951095
// survives a transport loss and would block that fresh reconnect.
10961096
void (async () => {
10971097
try {
1098-
const { config: resolvedConfig, resolvedIP } = await this.resolveConfigEnvVars(
1098+
const { config: resolvedConfig } = await resolveMcpConfigEnvVars(
10991099
config,
11001100
userId,
11011101
workspaceId
11021102
)
1103-
await manager.connect(resolvedConfig, userId, workspaceId, resolvedIP)
1103+
await manager.connect(resolvedConfig, userId, workspaceId)
11041104
} catch (err) {
11051105
logger.warn(`[${requestId}] Persistent connection failed for ${config.name}:`, err)
11061106
}

apps/sim/lib/mcp/types.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,11 @@ export interface McpClientOptions {
197197
securityPolicy?: McpSecurityPolicy
198198
onToolsChanged?: McpToolsChangedCallback
199199
/**
200-
* Pre-resolved IP address to pin all transport HTTP connections to. When
201-
* set, the SDK transport uses a custom fetch backed by an undici Agent with
202-
* a fixed DNS lookup, preventing DNS-rebinding (TOCTOU) attacks between
203-
* URL validation and connection. Should be supplied by callers that have
204-
* just validated the URL via `validateMcpServerSsrf`.
200+
* Address returned by `validateMcpServerSsrf` for this URL. A private/loopback
201+
* address (only permitted on a self-hosted deployment whose policy allows it)
202+
* pins every transport connection to it, so the name cannot rebind elsewhere
203+
* after validation. A public address or none leaves the transport on the SSRF
204+
* guard, which validates every connect and redirect hop itself.
205205
*/
206206
resolvedIP?: string
207207
/**

0 commit comments

Comments
 (0)