d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
525 lines
19 KiB
TypeScript
525 lines
19 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoisted(() => ({
|
|
mockGetAllowedMcpDomainsFromEnv: vi.fn<() => string[] | null>(),
|
|
mockDnsLookup: vi.fn(),
|
|
hostedFlag: { value: false },
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env-flags', () => ({
|
|
getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv,
|
|
get isHosted() {
|
|
return hostedFlag.value
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
|
|
|
inputValidationMockFns.mockIsPrivateOrReservedIP.mockImplementation((ip: string) => {
|
|
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true
|
|
if (ip.startsWith('172.')) {
|
|
const second = Number.parseInt(ip.split('.')[1], 10)
|
|
if (second >= 16 && second <= 31) return true
|
|
}
|
|
if (ip.startsWith('169.254.')) return true
|
|
if (ip.startsWith('127.') || ip === '::1') return true
|
|
if (ip === '0.0.0.0') return true
|
|
return false
|
|
})
|
|
|
|
vi.mock('dns/promises', () => ({
|
|
default: { lookup: mockDnsLookup },
|
|
}))
|
|
|
|
vi.mock('@/executor/utils/reference-validation', () => ({
|
|
createEnvVarPattern: () => /\{\{([^}]+)\}\}/g,
|
|
}))
|
|
|
|
import {
|
|
isMcpDomainAllowed,
|
|
McpDnsResolutionError,
|
|
McpDomainNotAllowedError,
|
|
McpSsrfError,
|
|
validateMcpDomain,
|
|
validateMcpServerSsrf,
|
|
} from './domain-check'
|
|
|
|
describe('McpDomainNotAllowedError', () => {
|
|
it.concurrent('creates error with correct name and message', () => {
|
|
const error = new McpDomainNotAllowedError('evil.com')
|
|
|
|
expect(error).toBeInstanceOf(Error)
|
|
expect(error).toBeInstanceOf(McpDomainNotAllowedError)
|
|
expect(error.name).toBe('McpDomainNotAllowedError')
|
|
expect(error.message).toContain('evil.com')
|
|
})
|
|
})
|
|
|
|
describe('isMcpDomainAllowed', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('when no allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
|
|
})
|
|
|
|
it('allows any URL', () => {
|
|
expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows undefined URL', () => {
|
|
expect(isMcpDomainAllowed(undefined)).toBe(true)
|
|
})
|
|
|
|
it('allows empty string URL', () => {
|
|
expect(isMcpDomainAllowed('')).toBe(true)
|
|
})
|
|
|
|
it('allows env var URLs', () => {
|
|
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
|
|
})
|
|
|
|
it('allows URLs with env vars anywhere', () => {
|
|
expect(isMcpDomainAllowed('https://server.com/{{PATH}}')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('when allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com'])
|
|
})
|
|
|
|
describe('basic domain matching', () => {
|
|
it('allows URLs on the allowlist', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
|
|
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
|
|
})
|
|
|
|
it('allows URLs with paths on allowlisted domains', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com/deep/path/to/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows URLs with query params on allowlisted domains', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com/mcp?key=value&foo=bar')).toBe(true)
|
|
})
|
|
|
|
it('allows URLs with ports on allowlisted domains', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com:8080/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows HTTP URLs on allowlisted domains', () => {
|
|
expect(isMcpDomainAllowed('http://allowed.com/mcp')).toBe(true)
|
|
})
|
|
|
|
it('matches case-insensitively', () => {
|
|
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
|
|
expect(isMcpDomainAllowed('https://Allowed.Com/mcp')).toBe(true)
|
|
})
|
|
|
|
it('rejects URLs not on the allowlist', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
|
|
})
|
|
|
|
it('rejects subdomains of allowed domains', () => {
|
|
expect(isMcpDomainAllowed('https://sub.allowed.com/mcp')).toBe(false)
|
|
})
|
|
|
|
it('rejects URLs with allowed domain in path only', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/allowed.com/mcp')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('fail-closed behavior', () => {
|
|
it('rejects undefined URL', () => {
|
|
expect(isMcpDomainAllowed(undefined)).toBe(false)
|
|
})
|
|
|
|
it('rejects empty string URL', () => {
|
|
expect(isMcpDomainAllowed('')).toBe(false)
|
|
})
|
|
|
|
it('rejects malformed URLs', () => {
|
|
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
|
|
})
|
|
|
|
it('rejects URLs with no protocol', () => {
|
|
expect(isMcpDomainAllowed('allowed.com/mcp')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('env var handling — hostname bypass', () => {
|
|
it('allows entirely env var URL', () => {
|
|
expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true)
|
|
})
|
|
|
|
it('allows env var URL with whitespace', () => {
|
|
expect(isMcpDomainAllowed(' {{MCP_SERVER_URL}} ')).toBe(true)
|
|
})
|
|
|
|
it('allows multiple env vars composing the entire URL', () => {
|
|
expect(isMcpDomainAllowed('{{PROTOCOL}}{{HOST}}{{PATH}}')).toBe(true)
|
|
})
|
|
|
|
it('allows env var in hostname portion', () => {
|
|
expect(isMcpDomainAllowed('https://{{MCP_HOST}}/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows env var as subdomain', () => {
|
|
expect(isMcpDomainAllowed('https://{{TENANT}}.company.com/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows env var in port (authority)', () => {
|
|
expect(isMcpDomainAllowed('https://{{HOST}}:{{PORT}}/mcp')).toBe(true)
|
|
})
|
|
|
|
it('allows env var as the full authority', () => {
|
|
expect(isMcpDomainAllowed('https://{{MCP_HOST}}:{{MCP_PORT}}/api/mcp')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('env var handling — no bypass when only in path/query', () => {
|
|
it('rejects disallowed domain with env var in path', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/{{MCP_PATH}}')).toBe(false)
|
|
})
|
|
|
|
it('rejects disallowed domain with env var in query', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/mcp?key={{API_KEY}}')).toBe(false)
|
|
})
|
|
|
|
it('rejects disallowed domain with env var in fragment', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/mcp#{{SECTION}}')).toBe(false)
|
|
})
|
|
|
|
it('allows allowlisted domain with env var in path', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com/{{MCP_PATH}}')).toBe(true)
|
|
})
|
|
|
|
it('allows allowlisted domain with env var in query', () => {
|
|
expect(isMcpDomainAllowed('https://allowed.com/mcp?key={{API_KEY}}')).toBe(true)
|
|
})
|
|
|
|
it('rejects disallowed domain with env var in both path and query', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/{{PATH}}?token={{TOKEN}}&key={{KEY}}')).toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
it('rejects disallowed domain with env var in query but no path', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com?token={{SECRET}}')).toBe(false)
|
|
})
|
|
|
|
it('rejects disallowed domain with env var in fragment but no path', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com#{{SECTION}}')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('env var security edge cases', () => {
|
|
it('rejects URL with env var only after allowed domain in path', () => {
|
|
expect(isMcpDomainAllowed('https://evil.com/allowed.com/{{VAR}}')).toBe(false)
|
|
})
|
|
|
|
it('rejects URL trying to use env var to sneak past domain check via userinfo', () => {
|
|
// https://evil.com@allowed.com would have hostname "allowed.com" per URL spec,
|
|
// but https://{{VAR}}@evil.com has env var in authority so it bypasses
|
|
expect(isMcpDomainAllowed('https://{{VAR}}@evil.com/mcp')).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('validateMcpDomain', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('when no allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
|
|
})
|
|
|
|
it('does not throw for any URL', () => {
|
|
expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow()
|
|
})
|
|
|
|
it('does not throw for undefined URL', () => {
|
|
expect(() => validateMcpDomain(undefined)).not.toThrow()
|
|
})
|
|
|
|
it('does not throw for empty string', () => {
|
|
expect(() => validateMcpDomain('')).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('when allowlist is configured', () => {
|
|
beforeEach(() => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
|
|
})
|
|
|
|
describe('basic validation', () => {
|
|
it('does not throw for allowed URLs', () => {
|
|
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
|
|
})
|
|
|
|
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
|
|
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
|
|
})
|
|
|
|
it('throws for undefined URL (fail-closed)', () => {
|
|
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
|
|
})
|
|
|
|
it('throws for malformed URLs', () => {
|
|
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
|
|
})
|
|
|
|
it('includes the rejected domain in the error message', () => {
|
|
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
|
|
})
|
|
|
|
it('includes "(empty)" in error for undefined URL', () => {
|
|
expect(() => validateMcpDomain(undefined)).toThrow(/\(empty\)/)
|
|
})
|
|
})
|
|
|
|
describe('env var handling', () => {
|
|
it('does not throw for entirely env var URL', () => {
|
|
expect(() => validateMcpDomain('{{MCP_SERVER_URL}}')).not.toThrow()
|
|
})
|
|
|
|
it('does not throw for env var in hostname', () => {
|
|
expect(() => validateMcpDomain('https://{{MCP_HOST}}/mcp')).not.toThrow()
|
|
})
|
|
|
|
it('does not throw for env var in authority', () => {
|
|
expect(() => validateMcpDomain('https://{{HOST}}:{{PORT}}/mcp')).not.toThrow()
|
|
})
|
|
|
|
it('throws for disallowed URL with env var only in path', () => {
|
|
expect(() => validateMcpDomain('https://evil.com/{{MCP_PATH}}')).toThrow(
|
|
McpDomainNotAllowedError
|
|
)
|
|
})
|
|
|
|
it('throws for disallowed URL with env var only in query', () => {
|
|
expect(() => validateMcpDomain('https://evil.com/mcp?key={{API_KEY}}')).toThrow(
|
|
McpDomainNotAllowedError
|
|
)
|
|
})
|
|
|
|
it('does not throw for allowed URL with env var in path', () => {
|
|
expect(() => validateMcpDomain('https://allowed.com/{{PATH}}')).not.toThrow()
|
|
})
|
|
|
|
it('throws for disallowed URL with env var in query but no path', () => {
|
|
expect(() => validateMcpDomain('https://evil.com?token={{SECRET}}')).toThrow(
|
|
McpDomainNotAllowedError
|
|
)
|
|
})
|
|
|
|
it('throws for disallowed URL with env var in fragment but no path', () => {
|
|
expect(() => validateMcpDomain('https://evil.com#{{SECTION}}')).toThrow(
|
|
McpDomainNotAllowedError
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('validateMcpServerSsrf', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
|
|
hostedFlag.value = false
|
|
})
|
|
|
|
it('returns null for undefined URL', async () => {
|
|
await expect(validateMcpServerSsrf(undefined)).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null and skips validation for env var URLs', async () => {
|
|
await expect(validateMcpServerSsrf('{{MCP_SERVER_URL}}')).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null and skips validation for URLs with env var in hostname', async () => {
|
|
await expect(validateMcpServerSsrf('https://{{MCP_HOST}}/mcp')).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null for localhost URLs without DNS lookup', async () => {
|
|
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null for 127.0.0.1 literal without DNS lookup', async () => {
|
|
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns resolved IP for URLs that resolve to public IPs', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
|
|
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
|
|
})
|
|
|
|
it('returns resolved IP for HTTP URLs on non-localhost hosts', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
|
|
await expect(validateMcpServerSsrf('http://example.com:3000/mcp')).resolves.toBe(
|
|
'93.184.216.34'
|
|
)
|
|
})
|
|
|
|
it('returns the literal IP for a public IPv4 literal so the caller pins it', async () => {
|
|
await expect(validateMcpServerSsrf('http://93.184.216.34:8080/mcp')).resolves.toBe(
|
|
'93.184.216.34'
|
|
)
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns the literal IP for a public IPv6 literal (brackets stripped)', async () => {
|
|
await expect(
|
|
validateMcpServerSsrf('http://[2606:2800:220:1:248:1893:25c8:1946]/mcp')
|
|
).resolves.toBe('2606:2800:220:1:248:1893:25c8:1946')
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('throws McpSsrfError for cloud metadata IP literal', async () => {
|
|
await expect(validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(
|
|
McpSsrfError
|
|
)
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('throws McpSsrfError for RFC-1918 IP literal', async () => {
|
|
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('throws McpSsrfError for 192.168.x.x IP literal', async () => {
|
|
await expect(validateMcpServerSsrf('http://192.168.1.1/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('throws McpSsrfError for URLs resolving to private IPs', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
|
|
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('throws McpSsrfError for URLs resolving to link-local IPs', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '169.254.169.254' })
|
|
await expect(validateMcpServerSsrf('https://metadata.internal/latest')).rejects.toThrow(
|
|
McpSsrfError
|
|
)
|
|
})
|
|
|
|
it('throws McpDnsResolutionError when DNS lookup fails', async () => {
|
|
mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'))
|
|
await expect(validateMcpServerSsrf('https://nonexistent.invalid/mcp')).rejects.toThrow(
|
|
McpDnsResolutionError
|
|
)
|
|
})
|
|
|
|
it('returns resolved IP for URLs resolving to loopback on self-hosted (localhost alias)', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
|
|
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).resolves.toBe('127.0.0.1')
|
|
})
|
|
|
|
it('throws for malformed URLs', async () => {
|
|
await expect(validateMcpServerSsrf('not-a-url')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
describe('hosted environment', () => {
|
|
beforeEach(() => {
|
|
hostedFlag.value = true
|
|
})
|
|
|
|
it('rejects localhost URLs on hosted', async () => {
|
|
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('rejects 127.0.0.1 URLs on hosted', async () => {
|
|
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('rejects [::1] URLs on hosted', async () => {
|
|
await expect(validateMcpServerSsrf('http://[::1]:8080/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('rejects URLs resolving to loopback on hosted', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
|
|
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).rejects.toThrow(
|
|
McpSsrfError
|
|
)
|
|
})
|
|
|
|
it('returns resolved IP for public IP resolutions on hosted', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
|
|
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
|
|
})
|
|
|
|
it('pins public IP literals on hosted so redirects cannot escape', async () => {
|
|
await expect(validateMcpServerSsrf('http://93.184.216.34/mcp')).resolves.toBe('93.184.216.34')
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips loopback check on hosted when allowlist is configured', async () => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['localhost'])
|
|
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
|
|
})
|
|
|
|
it('still blocks RFC-1918 IP literals on hosted (regression)', async () => {
|
|
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).rejects.toThrow(McpSsrfError)
|
|
await expect(validateMcpServerSsrf('http://192.168.1.1/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('still blocks cloud metadata IP on hosted (regression)', async () => {
|
|
await expect(
|
|
validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')
|
|
).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('still blocks DNS resolutions to private IPs on hosted (regression)', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
|
|
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
|
|
})
|
|
|
|
it('still skips env var hostnames on hosted', async () => {
|
|
await expect(validateMcpServerSsrf('{{MCP_SERVER_URL}}')).resolves.toBeNull()
|
|
await expect(validateMcpServerSsrf('https://{{MCP_HOST}}/mcp')).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('self-hosted environment (regression)', () => {
|
|
beforeEach(() => {
|
|
hostedFlag.value = false
|
|
})
|
|
|
|
it('still allows localhost URLs (returns null, no pinning needed)', async () => {
|
|
await expect(validateMcpServerSsrf('http://localhost:3000/mcp')).resolves.toBeNull()
|
|
})
|
|
|
|
it('still allows 127.0.0.1 URLs (returns null, no pinning needed)', async () => {
|
|
await expect(validateMcpServerSsrf('http://127.0.0.1:8080/mcp')).resolves.toBeNull()
|
|
})
|
|
|
|
it('returns resolved loopback IP for DNS aliases (caller pins)', async () => {
|
|
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
|
|
await expect(validateMcpServerSsrf('http://my-local-alias/mcp')).resolves.toBe('127.0.0.1')
|
|
})
|
|
})
|
|
|
|
it('skips all checks when ALLOWED_MCP_DOMAINS is configured', async () => {
|
|
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['internal.corp'])
|
|
await expect(validateMcpServerSsrf('http://10.0.0.1/mcp')).resolves.toBeNull()
|
|
await expect(
|
|
validateMcpServerSsrf('http://169.254.169.254/latest/meta-data/')
|
|
).resolves.toBeNull()
|
|
expect(mockDnsLookup).not.toHaveBeenCalled()
|
|
})
|
|
})
|