Skip to content

Commit 034a263

Browse files
authored
fix(deps): patch sharp librsvg CVE and MCP SDK OAuth issuer binding (#8798)
* fix(deps): patch sharp librsvg CVE and MCP SDK OAuth issuer binding - sharp 0.35.4 -> 0.35.5 (librsvg 2.63.2, CVE-2026-96889) - @modelcontextprotocol/sdk 1.29.0 -> 1.31.0 (GHSA-6qxp-vccf-f47h) - bind preregistered MCP OAuth client secrets to an issuer - skip token revocation when the advertised AS differs from the token issuer * fix(mcp): require a verified issuer before revocation and pin fixture issuers * fix(mcp): bind legacy pinned-client grants and cover issuer binding at the boundary * fix(mcp): prefer preregistered clients over stored registrations and tighten issuer boundary test
1 parent b62168b commit 034a263

10 files changed

Lines changed: 185 additions & 44 deletions

File tree

‎apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const SHARED_CLIENTS = [
5252
clientIdKey: 'HUBSPOT_MCP_CLIENT_ID',
5353
clientSecretKey: 'HUBSPOT_MCP_CLIENT_SECRET',
5454
url: 'https://mcp.hubspot.com',
55+
authorizationServer: 'https://mcp.hubspot.com',
5556
tokenAuthMethod: 'client_secret_post',
5657
scope: undefined,
5758
},
@@ -61,6 +62,7 @@ const SHARED_CLIENTS = [
6162
clientIdKey: 'ZOOM_MCP_CLIENT_ID',
6263
clientSecretKey: 'ZOOM_MCP_CLIENT_SECRET',
6364
url: 'https://mcp.zoom.us/mcp/meeting/streamable',
65+
authorizationServer: 'https://zoom.us',
6466
tokenAuthMethod: 'client_secret_basic',
6567
scope: 'meeting:read:search meeting:read:assets',
6668
},
@@ -208,6 +210,7 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => {
208210
expect(await loadPreregisteredClient(mcpServer.id)).toEqual({
209211
clientId: 'saved-client',
210212
clientSecret: 'saved-secret',
213+
issuer: connector.authorizationServer,
211214
})
212215
for (const change of [
213216
{ oauthClientId: null, oauthClientSecret: encrypted },
@@ -332,7 +335,7 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => {
332335
'restricts generic OAuth %s to registered read permissions',
333336
async (phase) => {
334337
const { mcpServer } = await create()
335-
const issuer = 'https://oauth.fixture.test'
338+
const issuer = connector.authorizationServer
336339
const loadProvider = async () =>
337340
new SimMcpOauthProvider({
338341
row: await getOrCreateOauthRow({ mcpServerId: mcpServer.id, organizationId: org }),
@@ -396,6 +399,72 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => {
396399
}
397400
)
398401
}
402+
it.each(['code exchange', 'legacy refresh'] as const)(
403+
'never presents shared credentials to a substituted authorization server on %s',
404+
async (phase) => {
405+
const { mcpServer } = await create()
406+
const substitute = 'https://oauth.substitute.test'
407+
const provider = new SimMcpOauthProvider({
408+
row: await getOrCreateOauthRow({ mcpServerId: mcpServer.id, organizationId: org }),
409+
preregistered: await loadPreregisteredClient(mcpServer.id),
410+
})
411+
if (phase === 'code exchange') await provider.saveCodeVerifier('fixture-verifier')
412+
else
413+
await provider.saveTokens({
414+
access_token: 'fixture-access',
415+
refresh_token: 'fixture-refresh',
416+
token_type: 'Bearer',
417+
})
418+
const disclosed: string[] = []
419+
const reached = new Set<string>()
420+
const fetchFn: typeof fetch = async (request, init) => {
421+
const url = new URL(
422+
typeof request === 'string' ? request : request instanceof URL ? request : request.url
423+
)
424+
if (url.origin === substitute) reached.add(url.pathname)
425+
const sent = `${new Headers(init?.headers).get('authorization') ?? ''} ${String(init?.body ?? '')}`
426+
if (
427+
url.origin === substitute &&
428+
(sent.includes(SECRET) ||
429+
sent.includes(Buffer.from(`fixture-shared-client:${SECRET}`).toString('base64')) ||
430+
sent.includes('fixture-refresh'))
431+
)
432+
disclosed.push(`${init?.method ?? 'GET'} ${url.pathname}`)
433+
if (url.pathname.includes('oauth-protected-resource'))
434+
return Response.json({ resource: connector.url, authorization_servers: [substitute] })
435+
if (
436+
url.pathname.includes('oauth-authorization-server') ||
437+
url.pathname.includes('openid-configuration')
438+
)
439+
return Response.json({
440+
issuer: substitute,
441+
authorization_endpoint: `${substitute}/authorize`,
442+
token_endpoint: `${substitute}/token`,
443+
registration_endpoint: `${substitute}/register`,
444+
response_types_supported: ['code'],
445+
code_challenge_methods_supported: ['S256'],
446+
})
447+
if (url.pathname === '/register')
448+
return Response.json({
449+
client_id: 'substitute-client',
450+
redirect_uris: [provider.redirectUrl],
451+
})
452+
if (url.pathname === '/token')
453+
return Response.json({ access_token: 'substitute-access', token_type: 'Bearer' })
454+
throw new Error(`Unexpected OAuth fixture request: ${url.origin}${url.pathname}`)
455+
}
456+
const attempt = oauth.mcpAuthGuarded(provider, {
457+
serverUrl: connector.url,
458+
fetchFn,
459+
...(phase === 'code exchange' ? { authorizationCode: 'fixture-code' } : {}),
460+
})
461+
if (phase === 'code exchange')
462+
await expect(attempt).rejects.toThrow(/Existing OAuth client information is required/)
463+
else await expect(attempt).rejects.toBeInstanceOf(McpOauthRedirectRequired)
464+
expect(reached.has('/.well-known/oauth-authorization-server')).toBe(true)
465+
expect(disclosed).toEqual([])
466+
}
467+
)
399468
it('binds the public OAuth round trip to the shared client and rejects rotation before exchange', async () => {
400469
const { mcpServer } = await create()
401470
const token = generateId()
@@ -427,7 +496,7 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => {
427496
})
428497
let exchanges = 0
429498
let challenge: string | null = null
430-
const issuer = 'https://oauth.fixture.test'
499+
const issuer = connector.authorizationServer
431500
/** Only provider HTTP is substituted; SDK, PKCE, Redis, use cases, encryption and database are real. */
432501
const fetchFn: typeof fetch = async (request, init) => {
433502
const url = new URL(

‎apps/sim/lib/credential-groups/managed-mcp-connectors.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ interface DatabricksManagedMcpConnector extends ManagedMcpConnectorMetadata {
3131
interface FixedPreregisteredManagedMcpConnector extends ManagedMcpConnectorMetadata {
3232
id: 'hubspot' | 'zoom'
3333
url: string
34+
/** The only authorization server the deployment client secret is ever presented to. */
35+
authorizationServer: string
3436
oauthClientRegistration: 'preregistered'
3537
}
3638

@@ -45,6 +47,7 @@ export const MANAGED_MCP_CONNECTORS = {
4547
name: 'Zoom',
4648
description: 'Search past meetings, transcripts and notes using your Zoom account',
4749
url: 'https://mcp.zoom.us/mcp/meeting/streamable',
50+
authorizationServer: 'https://zoom.us',
4851
oauthClientRegistration: 'preregistered',
4952
},
5053
lucid: {
@@ -60,6 +63,7 @@ export const MANAGED_MCP_CONNECTORS = {
6063
name: 'HubSpot',
6164
description: 'Search CRM records using each person’s HubSpot permissions',
6265
url: 'https://mcp.hubspot.com',
66+
authorizationServer: 'https://mcp.hubspot.com',
6367
oauthClientRegistration: 'preregistered',
6468
},
6569
coda: {

‎apps/sim/lib/mcp/oauth/managed-provider.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type {
66
} from '@modelcontextprotocol/sdk/shared/auth.js'
77
import { generateId } from '@sim/utils/id'
88
import { getBaseUrl } from '@/lib/core/utils/urls'
9-
import { McpOauthRedirectRequired, type PreregisteredClient } from '@/lib/mcp/oauth/provider'
9+
import {
10+
McpOauthRedirectRequired,
11+
type PreregisteredClient,
12+
withPinnedIssuer,
13+
} from '@/lib/mcp/oauth/provider'
1014
import { clearClient, type McpOauthRow, saveClientInformation } from '@/lib/mcp/oauth/storage'
1115

1216
interface ManagedMcpOauthProviderInit {
@@ -78,6 +82,7 @@ export class ManagedMcpOauthProvider implements OAuthClientProvider {
7882
token_endpoint_auth_method:
7983
this.preregistered.tokenEndpointAuthMethod ??
8084
(this.preregistered.clientSecret ? 'client_secret_post' : 'none'),
85+
issuer: this.preregistered.issuer ?? this.currentTokens?.issuer,
8186
}
8287
}
8388

@@ -88,7 +93,7 @@ export class ManagedMcpOauthProvider implements OAuthClientProvider {
8893
}
8994

9095
tokens(): OAuthTokens | undefined {
91-
return this.currentTokens
96+
return withPinnedIssuer(this.currentTokens, this.preregistered)
9297
}
9398

9499
async saveTokens(tokens: OAuthTokens): Promise<void> {

‎apps/sim/lib/mcp/oauth/provider.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ export interface PreregisteredClient {
4343
configurationFingerprint?: string
4444
scope?: string
4545
tokenEndpointAuthMethod?: 'client_secret_basic' | 'client_secret_post'
46+
/**
47+
* Authorization server the credentials are registered with. When unset, they are bound to
48+
* the issuer of the current grant, so the SDK never presents the secret to another server.
49+
*/
50+
issuer?: string
51+
}
52+
53+
/**
54+
* Grants saved before the SDK stamped `issuer` are otherwise refreshed against whichever
55+
* authorization server the MCP server names. A pinned registration could only have obtained
56+
* them from its own server, so they are bound to it.
57+
*/
58+
export function withPinnedIssuer(
59+
tokens: OAuthTokens | undefined,
60+
preregistered: PreregisteredClient | undefined
61+
): OAuthTokens | undefined {
62+
if (!tokens || tokens.issuer || !preregistered?.issuer) return tokens
63+
return { ...tokens, issuer: preregistered.issuer }
4664
}
4765

4866
interface SimMcpOauthProviderInit {
@@ -96,7 +114,6 @@ export class SimMcpOauthProvider implements OAuthClientProvider {
96114
}
97115

98116
clientInformation(): OAuthClientInformationMixed | undefined {
99-
if (this.row.clientInformation) return this.row.clientInformation
100117
if (this.preregistered) {
101118
return {
102119
client_id: this.preregistered.clientId,
@@ -107,9 +124,10 @@ export class SimMcpOauthProvider implements OAuthClientProvider {
107124
token_endpoint_auth_method:
108125
this.preregistered.tokenEndpointAuthMethod ??
109126
(this.preregistered.clientSecret ? 'client_secret_post' : 'none'),
127+
issuer: this.preregistered.issuer ?? this.row.tokens?.issuer,
110128
}
111129
}
112-
return undefined
130+
return this.row.clientInformation ?? undefined
113131
}
114132

115133
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
@@ -119,7 +137,7 @@ export class SimMcpOauthProvider implements OAuthClientProvider {
119137
}
120138

121139
tokens(): OAuthTokens | undefined {
122-
return this.row.tokens ?? undefined
140+
return withPinnedIssuer(this.row.tokens ?? undefined, this.preregistered)
123141
}
124142

125143
async saveTokens(tokens: OAuthTokens): Promise<void> {
@@ -226,6 +244,8 @@ export async function loadPreregisteredClient(
226244
if (!row.clientId || !row.clientSecret)
227245
throw new Error('HubSpot OAuth registration is incomplete')
228246
}
247+
const issuer =
248+
row.connectorId === 'hubspot' ? MANAGED_MCP_CONNECTORS.hubspot.authorizationServer : undefined
229249
if (!row.clientId) return undefined
230250
let clientSecret: string | undefined
231251
if (row.clientSecret) {
@@ -240,5 +260,5 @@ export async function loadPreregisteredClient(
240260
throw new Error('Failed to decrypt preregistered MCP OAuth client secret')
241261
}
242262
}
243-
return { clientId: row.clientId, clientSecret }
263+
return { clientId: row.clientId, clientSecret, issuer }
244264
}

‎apps/sim/lib/mcp/oauth/revoke.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ describe('revokeMcpOauthTokens — SSRF guard', () => {
7373
resetDbChainMock()
7474

7575
mockLoadOauthRow.mockResolvedValue({
76-
tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' },
76+
tokens: {
77+
access_token: 'access-secret',
78+
refresh_token: 'refresh-secret',
79+
issuer: PUBLIC_SERVER_URL,
80+
},
7781
clientInformation: { client_id: 'client-123' },
7882
})
7983

@@ -84,6 +88,7 @@ describe('revokeMcpOauthTokens — SSRF guard', () => {
8488
})
8589

8690
mockDiscoverOAuthServerInfo.mockResolvedValue({
91+
authorizationServerUrl: PUBLIC_SERVER_URL,
8792
authorizationServerMetadata: {
8893
issuer: PUBLIC_SERVER_URL,
8994
revocation_endpoint: BLOCKED_ENDPOINT,
@@ -118,6 +123,20 @@ describe('revokeMcpOauthTokens — SSRF guard', () => {
118123
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(BLOCKED_ENDPOINT, 'contentFetch')
119124
})
120125

126+
it.each([
127+
['issued by a different authorization server', 'https://previous-issuer.example.com'],
128+
['without a recorded issuer', undefined],
129+
])('never revokes tokens %s', async (_case, issuer) => {
130+
mockLoadOauthRow.mockResolvedValue({
131+
tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret', issuer },
132+
clientInformation: { client_id: 'client-123' },
133+
})
134+
135+
await revokeMcpOauthTokens('server-1', 'workspace-1')
136+
137+
expect(mockValidateMcpServerSsrf).not.toHaveBeenCalledWith(BLOCKED_ENDPOINT, 'contentFetch')
138+
})
139+
121140
it('never issues an outbound request to the blocked revocation endpoint', async () => {
122141
await revokeMcpOauthTokens('server-1', 'workspace-1')
123142

‎apps/sim/lib/mcp/oauth/revoke.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ export async function revokeMcpOauthTokens(
4141
const info = await discoverOAuthServerInfo(server.url, { fetchFn: ssrfGuardedFetch }).catch(
4242
() => undefined
4343
)
44-
const metadata = info?.authorizationServerMetadata as
44+
if (
45+
!info ||
46+
!row.tokens.issuer ||
47+
!isSameAuthorizationServer(row.tokens.issuer, info.authorizationServerUrl)
48+
) {
49+
return
50+
}
51+
const metadata = info.authorizationServerMetadata as
4552
| (Record<string, unknown> & { revocation_endpoint?: string })
4653
| undefined
4754
const revocationEndpoint = metadata?.revocation_endpoint
@@ -79,6 +86,21 @@ export async function revokeMcpOauthTokens(
7986
}
8087
}
8188

89+
/**
90+
* Tokens are only posted, with the client secret, to the authorization server the SDK stamped
91+
* them for. Unstamped legacy tokens are never revoked: their issuer cannot be verified.
92+
*/
93+
function isSameAuthorizationServer(issuer: string, authorizationServerUrl: string): boolean {
94+
const normalize = (value: string) => {
95+
try {
96+
return new URL(value).href
97+
} catch {
98+
return value
99+
}
100+
}
101+
return normalize(issuer) === normalize(authorizationServerUrl)
102+
}
103+
82104
async function postRevoke(
83105
endpoint: string,
84106
token: string,

‎apps/sim/lib/mcp/oauth/shared-clients.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function getSharedHubSpotMcpClient() {
1515
return {
1616
clientId,
1717
clientSecret,
18+
issuer: MANAGED_MCP_CONNECTORS.hubspot.authorizationServer,
1819
configurationFingerprint: sha256Hex(
1920
JSON.stringify([
2021
'shared-hubspot-mcp',
@@ -40,6 +41,7 @@ export function getSharedZoomMcpClient() {
4041
clientSecret,
4142
scope,
4243
tokenEndpointAuthMethod,
44+
issuer: MANAGED_MCP_CONNECTORS.zoom.authorizationServer,
4345
configurationFingerprint: sha256Hex(
4446
JSON.stringify([
4547
'shared-zoom-mcp',

‎apps/sim/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"@hookform/resolvers": "5.2.2",
8989
"@linear/sdk": "91.0.0",
9090
"@marsidev/react-turnstile": "1.4.2",
91-
"@modelcontextprotocol/sdk": "1.29.0",
91+
"@modelcontextprotocol/sdk": "1.31.0",
9292
"@monaco-editor/react": "4.7.0",
9393
"@napi-rs/canvas": "0.1.100",
9494
"@opentelemetry/api": "^1.9.0",
@@ -247,7 +247,7 @@
247247
"remark-stringify": "11.0.0",
248248
"resend": "^4.1.2",
249249
"rss-parser": "3.13.0",
250-
"sharp": "0.35.4",
250+
"sharp": "0.35.5",
251251
"sim": "workspace:*",
252252
"socket.io-client": "4.8.1",
253253
"ssh2": "^1.17.0",

0 commit comments

Comments
 (0)