chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Regression test: `revokeMcpOauthTokens` must route both metadata discovery
|
||||
* and the RFC 7009 revocation POST through the SSRF-guarded fetch, since
|
||||
* `revocation_endpoint` comes from attacker-controlled server metadata. Uses
|
||||
* the real `createSsrfGuardedMcpFetch` so it fails if revoke.ts regresses to a
|
||||
* raw `fetch`.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const BLOCKED_ENDPOINT = 'http://169.254.170.2/v2/credentials/'
|
||||
const PUBLIC_SERVER_URL = 'https://mcp.attacker.com'
|
||||
const PUBLIC_SERVER_IP = '203.0.113.10'
|
||||
|
||||
const {
|
||||
mockUndiciFetch,
|
||||
mockValidateMcpServerSsrf,
|
||||
mockDiscoverOAuthServerInfo,
|
||||
mockLoadOauthRow,
|
||||
mockDecryptSecret,
|
||||
mockDbSelect,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUndiciFetch: vi.fn(),
|
||||
mockValidateMcpServerSsrf: vi.fn(),
|
||||
mockDiscoverOAuthServerInfo: vi.fn(),
|
||||
mockLoadOauthRow: vi.fn(),
|
||||
mockDecryptSecret: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => ({
|
||||
createPinnedFetch: vi.fn(() => mockUndiciFetch),
|
||||
}))
|
||||
vi.mock('@/lib/mcp/domain-check', () => ({
|
||||
validateMcpServerSsrf: mockValidateMcpServerSsrf,
|
||||
}))
|
||||
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
|
||||
discoverOAuthServerInfo: mockDiscoverOAuthServerInfo,
|
||||
}))
|
||||
vi.mock('@/lib/mcp/oauth/storage', () => ({
|
||||
loadOauthRow: mockLoadOauthRow,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
decryptSecret: mockDecryptSecret,
|
||||
}))
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: mockDbSelect },
|
||||
}))
|
||||
|
||||
import { revokeMcpOauthTokens } from './revoke'
|
||||
|
||||
function wireServerRow(row: Record<string, unknown>) {
|
||||
const builder = {
|
||||
from: () => builder,
|
||||
where: () => builder,
|
||||
limit: () => Promise.resolve([row]),
|
||||
}
|
||||
mockDbSelect.mockReturnValue(builder)
|
||||
}
|
||||
|
||||
describe('revokeMcpOauthTokens — SSRF guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockLoadOauthRow.mockResolvedValue({
|
||||
tokens: { access_token: 'access-secret', refresh_token: 'refresh-secret' },
|
||||
clientInformation: { client_id: 'client-123' },
|
||||
})
|
||||
|
||||
wireServerRow({
|
||||
url: PUBLIC_SERVER_URL,
|
||||
oauthClientId: 'client-123',
|
||||
oauthClientSecret: null,
|
||||
})
|
||||
|
||||
mockDiscoverOAuthServerInfo.mockResolvedValue({
|
||||
authorizationServerMetadata: {
|
||||
issuer: PUBLIC_SERVER_URL,
|
||||
revocation_endpoint: BLOCKED_ENDPOINT,
|
||||
},
|
||||
})
|
||||
|
||||
mockUndiciFetch.mockResolvedValue(new Response('ok'))
|
||||
|
||||
// Catches a regression to raw globalThis.fetch without hitting the network.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'))
|
||||
|
||||
// Public server host resolves; the revocation endpoint is blocked.
|
||||
mockValidateMcpServerSsrf.mockImplementation(async (target: string) => {
|
||||
if (target.startsWith(BLOCKED_ENDPOINT) || target.includes('169.254.')) {
|
||||
throw new Error('MCP server URL resolves to a blocked IP address')
|
||||
}
|
||||
return PUBLIC_SERVER_IP
|
||||
})
|
||||
})
|
||||
|
||||
it('routes metadata discovery through the SSRF-guarded fetch', async () => {
|
||||
await revokeMcpOauthTokens('server-1')
|
||||
|
||||
expect(mockDiscoverOAuthServerInfo).toHaveBeenCalledTimes(1)
|
||||
const [, options] = mockDiscoverOAuthServerInfo.mock.calls[0]
|
||||
expect(typeof options?.fetchFn).toBe('function')
|
||||
})
|
||||
|
||||
it('validates the attacker-controlled revocation_endpoint before issuing the request', async () => {
|
||||
await revokeMcpOauthTokens('server-1')
|
||||
|
||||
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(BLOCKED_ENDPOINT)
|
||||
})
|
||||
|
||||
it('never issues an outbound request to the blocked revocation endpoint', async () => {
|
||||
await revokeMcpOauthTokens('server-1')
|
||||
|
||||
const allCalls = [
|
||||
...mockUndiciFetch.mock.calls,
|
||||
...(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls,
|
||||
]
|
||||
for (const call of allCalls) {
|
||||
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
|
||||
expect(target).not.toContain('169.254.170.2')
|
||||
}
|
||||
})
|
||||
|
||||
it('swallows the SSRF rejection — revocation is best-effort and never throws', async () => {
|
||||
await expect(revokeMcpOauthTokens('server-1')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('still issues the revocation POST when the endpoint resolves to a public IP', async () => {
|
||||
const publicEndpoint = 'https://mcp.attacker.com/oauth/revoke'
|
||||
mockDiscoverOAuthServerInfo.mockResolvedValue({
|
||||
authorizationServerMetadata: {
|
||||
issuer: PUBLIC_SERVER_URL,
|
||||
revocation_endpoint: publicEndpoint,
|
||||
},
|
||||
})
|
||||
|
||||
await revokeMcpOauthTokens('server-1')
|
||||
|
||||
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith(publicEndpoint)
|
||||
const revokeCalls = mockUndiciFetch.mock.calls.filter((call) => {
|
||||
const target = typeof call[0] === 'string' ? call[0] : String(call[0])
|
||||
return target === publicEndpoint
|
||||
})
|
||||
expect(revokeCalls.length).toBeGreaterThan(0)
|
||||
expect(revokeCalls[0][1]).toMatchObject({ method: 'POST' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user