improvement(deps): shared Standard Webhooks verifier, MCP SDK 1.32.1, tiptap 3.31.4 with the prosemirror paste XSS fix by waleedlatif1 · Pull Request #9236 · simstudioai/sim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
improvement(mcp): upgrade MCP SDK to 1.32.1 and keep cross-origin red…
…irect handling in our SSRF guard
  • Loading branch information
waleedlatif1 committed Oct 19, 2026
commit 84ef0d3928ded3aed5eca4d70bfc735c6064dd5c
2 changes: 1 addition & 1 deletion apps/sim/app/api/mcp/oauth/start/route.ts
3 changes: 3 additions & 0 deletions apps/sim/lib/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export class McpClient {
authProvider: useOauth ? this.authProvider : undefined,
...(useOauth ? {} : { requestInit: { headers: this.config.headers } }),
fetch: (input, init) => runInOutboundScope(() => transportFetch(input, init)),
// The SDK's default ('same-origin') refuses cross-origin redirects; the guarded
// fetch already follows them with per-hop SSRF checks and cross-origin header stripping.
redirectPolicy: 'follow',
})

this.client = new Client(
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/lib/mcp/pinned-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,14 @@ async function bufferUnderDeadline(response: Response, signal: AbortSignal): Pro
headers.delete('content-encoding')
headers.delete('content-length')
const nullBody = response.status === 204 || response.status === 205 || response.status === 304
return new Response(nullBody ? null : body, {
const buffered = new Response(nullBody ? null : body, {
status: response.status,
statusText: response.statusText,
headers,
})
// The SDK names an unfollowed redirect's target by resolving `Location` against `response.url`.
Object.defineProperty(buffered, 'url', { value: response.url, configurable: true })
return buffered
}

/**
Expand Down Expand Up @@ -334,7 +337,9 @@ function releaseStreamOnSettle(
* attacker-controllable authorization-server metadata, so every request is re-validated
* against the SSRF policy up front (fail fast, friendly errors) AND at socket-connect
* time via the guarded lookup, with redirects followed manually under per-hop
* validation (IP-literal targets included) and cross-origin header stripping.
* validation (IP-literal targets included) and cross-origin header stripping. The SDK's own
* `auth()` legs pass `redirect: 'manual'`: the 3xx comes back buffered, and the SDK follows
* it only within the same origin, re-entering this guard for each hop.
*
* The SDK provides none of the safety itself (no timeout on OAuth legs, lazy body reads),
* so the guard owns it: the `timeoutMs` deadline covers validation + request + the buffered
Expand Down
170 changes: 170 additions & 0 deletions apps/sim/lib/mcp/transport-redirects.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Redirects across the MCP SDK and our SSRF guard, over real loopback sockets.
* Nothing between the SDK and the wire is mocked: two local servers on different
* ports are two origins, and loopback is reachable under the self-hosted profile.
*/
import { createServer, type IncomingHttpHeaders, type Server } from 'node:http'
import type { AddressInfo, Socket } from 'node:net'
import { exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js'
import { ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { McpClient } from '@/lib/mcp/client'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'

interface RecordedRequest {
method: string
url: string
headers: IncomingHttpHeaders
}

interface LocalServer {
origin: string
requests: RecordedRequest[]
openSockets: Set<Socket>
close: () => Promise<void>
}

type Handler = (
request: RecordedRequest,
body: string,
respond: (status: number, headers: Record<string, string>, body?: string) => void
) => void

const servers: LocalServer[] = []

async function startServer(handler: Handler): Promise<LocalServer> {
const requests: RecordedRequest[] = []
const openSockets = new Set<Socket>()
const server: Server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
const recorded = { method: req.method ?? '', url: req.url ?? '', headers: req.headers }
requests.push(recorded)
handler(recorded, Buffer.concat(chunks).toString('utf8'), (status, headers, body) => {
res.writeHead(status, headers)
if (body === undefined) res.flushHeaders()
else res.end(body)
})
})
})
server.on('connection', (socket) => {
openSockets.add(socket)
socket.on('close', () => openSockets.delete(socket))
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address() as AddressInfo
const local: LocalServer = {
origin: `http://127.0.0.1:${port}`,
requests,
openSockets,
close: () =>
new Promise<void>((resolve) => {
server.closeAllConnections()
server.close(() => resolve())
}),
}
servers.push(local)
return local
}

afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.close()))
})

describe('MCP transport redirects', () => {
it('follows a cross-origin redirect of the SSE stream through the guard, without the configured credentials', async () => {
const www = await startServer((_request, _body, respond) => {
respond(200, { 'content-type': 'text/event-stream' })
})
const apex = await startServer((request, body, respond) => {
if (request.method === 'GET') {
respond(302, { location: `${www.origin}/mcp` }, '')
return
}
if (request.method === 'DELETE') {
respond(200, {}, '')
return
}
const message = JSON.parse(body) as { id?: number; method: string }
if (message.method !== 'initialize') {
respond(202, {}, '')
return
}
respond(
200,
{ 'content-type': 'application/json', 'mcp-session-id': 'session-1' },
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: { name: 'apex', version: '1.0.0' },
},
})
)
})

const client = new McpClient({
config: {
id: 'server-1',
name: 'Apex',
transport: 'streamable-http',
url: `${apex.origin}/mcp`,
headers: { Authorization: 'Bearer configured-secret', 'X-Api-Key': 'configured-key' },
},
})
try {
await client.connect()
await vi.waitFor(() => expect(www.requests).toHaveLength(1), { timeout: 5_000 })
} finally {
await client.disconnect()
}

const apexStream = apex.requests.find((request) => request.method === 'GET')
expect(apexStream?.headers.authorization).toBe('Bearer configured-secret')
const [redirected] = www.requests
expect(redirected.method).toBe('GET')
expect(redirected.url).toBe('/mcp')
expect(redirected.headers.authorization).toBeUndefined()
expect(redirected.headers['x-api-key']).toBeUndefined()
expect(redirected.headers['mcp-session-id']).toBeUndefined()
})
})

describe('guarded MCP OAuth fetch', () => {
it('hands an unfollowed cross-origin token redirect back to the SDK, naming its target, and releases the socket', async () => {
const elsewhere = await startServer((_request, _body, respond) => respond(200, {}, '{}'))
const authServer = await startServer((_request, _body, respond) => {
respond(302, { location: `${elsewhere.origin}/token` }, 'moved')
})

const exchange = exchangeAuthorization(authServer.origin, {
metadata: {
issuer: authServer.origin,
authorization_endpoint: `${authServer.origin}/authorize`,
token_endpoint: `${authServer.origin}/token`,
response_types_supported: ['code'],
},
clientInformation: { client_id: 'client-1' },
authorizationCode: 'code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://127.0.0.1/callback',
fetchFn: createSsrfGuardedMcpFetch({
serverUrl: `${authServer.origin}/mcp`,
timeoutMs: 5_000,
}),
})

const error = await exchange.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(ServerError)
expect((error as Error).message).toBe(
`HTTP 302: Redirect to ${elsewhere.origin}/token not followed`
)
expect(authServer.requests.map((request) => request.method)).toEqual(['POST'])
expect(elsewhere.requests).toHaveLength(0)
await vi.waitFor(() => expect(authServer.openSockets.size).toBe(0), { timeout: 2_000 })
})
})
2 changes: 1 addition & 1 deletion apps/sim/package.json
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.