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,215 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user