diff --git a/package.json b/package.json index 1c04927..b9f87be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-api", - "version": "1.11.7", + "version": "1.11.8", "description": "MCP Servers exposed via HTTP API", "main": "dist/index.js", "repository": "missionsquad/mcp-api", diff --git a/src/services/oauthTokens.ts b/src/services/oauthTokens.ts index 2e636dd..5a7911b 100644 --- a/src/services/oauthTokens.ts +++ b/src/services/oauthTokens.ts @@ -114,6 +114,7 @@ const oauthLifecycleInfo = (msg: string): void => { } const ACCESS_TOKEN_EXPIRY_SKEW_MS = 30_000 +const OAUTH_REFRESH_UNSUPPORTED_GRANT_REAUTH_SERVER_NAMES = new Set(['google-workspace']) export class McpOAuthTokens { private encryptor: SecretEncryptor @@ -583,6 +584,16 @@ export class McpOAuthClientProvider implements OAuthClientProvider { tokenEndpoint: summarizeUrlForLog(this.tokenEndpoint), resource: summarizeUrlForLog(this.resource) })}`) + if ( + OAUTH_REFRESH_UNSUPPORTED_GRANT_REAUTH_SERVER_NAMES.has(this.serverName) && + /unsupported_grant_type/i.test(message) + ) { + throw new McpReauthRequiredError({ + serverName: this.serverName, + username: this.username, + message: 'This OAuth provider does not support client-side refresh_token renewal. Reconnect the server.' + }) + } if (/invalid_grant|invalid_token/i.test(message)) { throw new McpReauthRequiredError({ serverName: this.serverName, diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts index 195b0ee..bdecadd 100644 --- a/src/utils/ssrf.ts +++ b/src/utils/ssrf.ts @@ -52,6 +52,22 @@ function assertSafeIp(address: string): void { } } +function getTrustedPrivateHostnameAllowlist(): string[] { + return (process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST ?? '') + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0) +} + +function isHostnameAllowlisted(hostname: string, allowlist: string[]): boolean { + return allowlist.some((entry) => { + if (entry.startsWith('.')) { + return hostname.endsWith(entry) + } + return hostname === entry + }) +} + export async function validateExternalMcpUrl(urlString: string): Promise { let parsed: URL try { @@ -83,11 +99,15 @@ export async function validateExternalMcpUrl(urlString: string): Promise { return parsed } + const trustedPrivateHostnameAllowlist = getTrustedPrivateHostnameAllowlist() + const allowlistedHostname = isHostnameAllowlisted(hostname, trustedPrivateHostnameAllowlist) const resolved = await dns.lookup(hostname, { all: true, verbatim: true }) if (resolved.length === 0) { throw new McpValidationError(`Unable to resolve external MCP hostname: ${hostname}`) } - resolved.forEach(({ address }) => assertSafeIp(address)) + if (!allowlistedHostname) { + resolved.forEach(({ address }) => assertSafeIp(address)) + } return parsed } diff --git a/test/mcp-external-auth.spec.ts b/test/mcp-external-auth.spec.ts index 15fb1fb..e8d5858 100644 --- a/test/mcp-external-auth.spec.ts +++ b/test/mcp-external-auth.spec.ts @@ -25,6 +25,7 @@ import { import { McpOAuthClientProvider, McpOAuthTokens } from '../src/services/oauthTokens' import { resolveInitialUserInstallAuthState } from '../src/services/userServerInstalls' import { resolvePreferredTokenEndpointAuthMethod } from '../src/services/dcrClients' +import { validateExternalMcpUrl } from '../src/utils/ssrf' const originalFetch = global.fetch @@ -187,6 +188,38 @@ describe('external MCP request validation', () => { ).toBe(true) }) + test('rejects external MCP hostnames that resolve to private IPs by default', async () => { + const lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation(async () => + [{ address: '10.1.253.40', family: 4 }] as any + ) + + await expect(validateExternalMcpUrl('https://googlemcp.missionsquad.ai/mcp')).rejects.toThrow( + 'Blocked private or local IPv4 address: 10.1.253.40' + ) + + lookupSpy.mockRestore() + }) + + test('allows trusted MCP hostnames that resolve to private IPs when explicitly allowlisted', async () => { + const previousAllowlist = process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST + process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST = 'googlemcp.missionsquad.ai' + const lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation(async () => + [{ address: '10.1.253.40', family: 4 }] as any + ) + + await expect(validateExternalMcpUrl('https://googlemcp.missionsquad.ai/mcp')).resolves.toMatchObject({ + hostname: 'googlemcp.missionsquad.ai', + pathname: '/mcp' + }) + + if (previousAllowlist === undefined) { + delete process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST + } else { + process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST = previousAllowlist + } + lookupSpy.mockRestore() + }) + test('rejects reserved OAuth authorization request params on external OAuth templates', () => { const service = new MCPService({ mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, @@ -371,6 +404,49 @@ describe('external MCP error contract', () => { }) }) + test('google-workspace maps unsupported refresh grants to reauth-required', async () => { + const expiredRecord = { + serverName: 'google-workspace', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'expired-access', + refreshToken: 'refresh-token', + clientId: 'client-id', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'none' as const, + registrationMode: 'cimd' as const, + expiresAt: new Date(Date.now() - 60_000), + scopes: ['openid'], + createdAt: new Date('2026-03-13T00:00:00.000Z'), + updatedAt: new Date('2026-03-13T00:00:00.000Z') + } + const tokenStore = { + getTokenRecord: jest.fn().mockResolvedValue(expiredRecord), + refreshTokenRecord: jest.fn().mockRejectedValue( + new Error("OAuth token refresh failed: unsupported_grant_type Unsupported grant type (supported grant types are ['authorization_code'])") + ) + } as unknown as McpOAuthTokens + + const provider = new McpOAuthClientProvider({ + serverName: 'google-workspace', + username: 'alice', + tokenStore, + record: expiredRecord, + tokenEndpoint: 'https://googlemcp.missionsquad.ai/token', + resource: 'https://googlemcp.missionsquad.ai/mcp' + }) + + await expect(provider.tokens()).rejects.toMatchObject({ + code: 'reauth_required', + statusCode: 401, + details: { + reauthRequired: true, + serverName: 'google-workspace', + username: 'alice' + } + }) + }) + test('oauth provider returns the persisted refresh token to the MCP SDK', async () => { const noSecretRecord = { serverName: 'webflow',