d25d482dc2
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
216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
import dns from 'dns/promises'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import * as ipaddr from 'ipaddr.js'
|
|
import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags'
|
|
import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server'
|
|
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
|
|
|
const logger = createLogger('McpDomainCheck')
|
|
|
|
export class McpDomainNotAllowedError extends Error {
|
|
constructor(domain: string) {
|
|
super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`)
|
|
this.name = 'McpDomainNotAllowedError'
|
|
}
|
|
}
|
|
|
|
export class McpSsrfError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'McpSsrfError'
|
|
}
|
|
}
|
|
|
|
export class McpDnsResolutionError extends Error {
|
|
constructor(hostname: string) {
|
|
super(`MCP server URL hostname "${hostname}" could not be resolved`)
|
|
this.name = 'McpDnsResolutionError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Core domain check. Returns null if the URL is allowed, or the hostname/url
|
|
* string to use in the rejection error.
|
|
*/
|
|
function checkMcpDomain(url: string): string | null {
|
|
const allowedDomains = getAllowedMcpDomainsFromEnv()
|
|
if (allowedDomains === null) return null
|
|
try {
|
|
const hostname = new URL(url).hostname.toLowerCase()
|
|
return allowedDomains.includes(hostname) ? null : hostname
|
|
} catch {
|
|
return url
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the URL's hostname contains an env var reference,
|
|
* meaning domain validation must be deferred until env var resolution.
|
|
* Only bypasses validation when the hostname itself is unresolvable —
|
|
* env vars in the path/query do NOT bypass the domain check.
|
|
*/
|
|
function hasEnvVarInHostname(url: string): boolean {
|
|
// If the entire URL is an env var reference, hostname is unknown
|
|
if (url.trim().replace(createEnvVarPattern(), '').trim() === '') return true
|
|
try {
|
|
// Extract the authority portion (between :// and the first /, ?, or # per RFC 3986)
|
|
const protocolEnd = url.indexOf('://')
|
|
if (protocolEnd === -1) return createEnvVarPattern().test(url)
|
|
const afterProtocol = url.substring(protocolEnd + 3)
|
|
const authorityEnd = afterProtocol.search(/[/?#]/)
|
|
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
|
|
return createEnvVarPattern().test(authority)
|
|
} catch {
|
|
return createEnvVarPattern().test(url)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the URL's domain is allowed (or no restriction is configured).
|
|
* URLs with env var references in the hostname are allowed — they will be
|
|
* validated after resolution at execution time.
|
|
*/
|
|
export function isMcpDomainAllowed(url: string | undefined): boolean {
|
|
if (!url) {
|
|
return getAllowedMcpDomainsFromEnv() === null
|
|
}
|
|
if (hasEnvVarInHostname(url)) return true
|
|
return checkMcpDomain(url) === null
|
|
}
|
|
|
|
/**
|
|
* Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist.
|
|
* URLs with env var references in the hostname are skipped — they will be
|
|
* validated after resolution at execution time.
|
|
*/
|
|
export function validateMcpDomain(url: string | undefined): void {
|
|
if (!url) {
|
|
if (getAllowedMcpDomainsFromEnv() !== null) {
|
|
throw new McpDomainNotAllowedError('(empty)')
|
|
}
|
|
return
|
|
}
|
|
if (hasEnvVarInHostname(url)) return
|
|
const rejected = checkMcpDomain(url)
|
|
if (rejected !== null) {
|
|
throw new McpDomainNotAllowedError(rejected)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the IP is a loopback address (full 127.0.0.0/8 range, or ::1).
|
|
*/
|
|
function isLoopbackIP(ip: string): boolean {
|
|
try {
|
|
if (!ipaddr.isValid(ip)) return false
|
|
return ipaddr.process(ip).range() === 'loopback'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the hostname is localhost or a loopback IP literal.
|
|
* Expects IPv6 brackets to already be stripped.
|
|
*/
|
|
function isLocalhostHostname(hostname: string): boolean {
|
|
const clean = hostname.toLowerCase()
|
|
if (clean === 'localhost') return true
|
|
return ipaddr.isValid(clean) && isLoopbackIP(clean)
|
|
}
|
|
|
|
/**
|
|
* Validates an MCP server URL against SSRF attacks by resolving DNS and
|
|
* rejecting private/reserved IP ranges (RFC-1918, link-local, cloud metadata).
|
|
*
|
|
* Only active when ALLOWED_MCP_DOMAINS is **not configured**. When an admin
|
|
* has set an explicit domain allowlist, they control which domains are
|
|
* reachable and private-network MCP servers are legitimate. Applying SSRF
|
|
* blocking on top of an admin-curated list would break self-hosted
|
|
* deployments where MCP servers run on internal networks.
|
|
*
|
|
* Does NOT enforce protocol (HTTP is allowed) or block service ports — MCP
|
|
* servers legitimately run on HTTP and on arbitrary ports.
|
|
*
|
|
* Localhost/loopback is allowed for local dev MCP servers in self-hosted
|
|
* deployments, but blocked on the hosted environment (sim.ai) where users
|
|
* must not be able to reach the server's own loopback interface.
|
|
* URLs with env var references in the hostname are skipped — they will be
|
|
* validated after resolution at execution time.
|
|
*
|
|
* Returns the IP address to pin subsequent connections to (the resolved IP for
|
|
* hostnames, or the literal itself for public IP-literal URLs) so the caller can
|
|
* prevent DNS-rebinding TOCTOU attacks and stop redirects from escaping to
|
|
* internal hosts. Pinning matters for IP literals too: without it the transport
|
|
* uses the default fetch, which follows an attacker-controlled 3xx redirect to a
|
|
* private/metadata address. Returns null only when pinning is unnecessary or
|
|
* impossible: no URL, allowlist-only mode, env-var hostnames (validated later),
|
|
* and localhost on self-hosted (no rebinding risk against a fixed loopback).
|
|
*
|
|
* @throws McpSsrfError if the URL resolves to a blocked IP address
|
|
*/
|
|
export async function validateMcpServerSsrf(url: string | undefined): Promise<string | null> {
|
|
if (!url) return null
|
|
if (getAllowedMcpDomainsFromEnv() !== null) return null
|
|
if (hasEnvVarInHostname(url)) return null
|
|
|
|
let hostname: string
|
|
try {
|
|
hostname = new URL(url).hostname
|
|
} catch {
|
|
throw new McpSsrfError('MCP server URL is not a valid URL')
|
|
}
|
|
|
|
const cleanHostname =
|
|
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
|
|
|
|
if (isLocalhostHostname(cleanHostname)) {
|
|
if (isHosted) {
|
|
throw new McpSsrfError('MCP server URL cannot point to a loopback address')
|
|
}
|
|
return null
|
|
}
|
|
|
|
if (ipaddr.isValid(cleanHostname)) {
|
|
if (isPrivateOrReservedIP(cleanHostname)) {
|
|
throw new McpSsrfError('MCP server URL cannot point to a private or reserved IP address')
|
|
}
|
|
// Public IP literal: pin to this exact address so the caller's pinned fetch
|
|
// (createPinnedFetch) keeps every redirect hop on it. Returning null here
|
|
// would fall back to the default fetch, which follows a 3xx redirect to a
|
|
// private/metadata host and escapes SSRF controls.
|
|
return cleanHostname
|
|
}
|
|
|
|
let address: string
|
|
try {
|
|
const lookup = await dns.lookup(cleanHostname, { verbatim: true })
|
|
address = lookup.address
|
|
} catch (error) {
|
|
logger.warn('DNS lookup failed for MCP server URL', {
|
|
hostname,
|
|
error: toError(error).message,
|
|
})
|
|
throw new McpDnsResolutionError(cleanHostname)
|
|
}
|
|
|
|
if (isLoopbackIP(address)) {
|
|
if (isHosted) {
|
|
logger.warn('MCP server URL resolves to loopback address', {
|
|
hostname,
|
|
resolvedIP: address,
|
|
})
|
|
throw new McpSsrfError('MCP server URL resolves to a loopback address')
|
|
}
|
|
} else if (isPrivateOrReservedIP(address)) {
|
|
logger.warn('MCP server URL resolves to blocked IP address', {
|
|
hostname,
|
|
resolvedIP: address,
|
|
})
|
|
throw new McpSsrfError('MCP server URL resolves to a blocked IP address')
|
|
}
|
|
|
|
return address
|
|
}
|