diff --git a/.changeset/mcp-url-allowlist-and-ssrf-defense.md b/.changeset/mcp-url-allowlist-and-ssrf-defense.md new file mode 100644 index 00000000..88358da6 --- /dev/null +++ b/.changeset/mcp-url-allowlist-and-ssrf-defense.md @@ -0,0 +1,12 @@ +--- +"lens": minor +--- + +Enforce URL allowlisting and SSRF defense on Bazaar MCP server: +- Added `ERR_HOST_NOT_ALLOWED`, `ERR_PRIVATE_ADDRESS_REFUSED`, and `ERR_INVALID_URL` error codes to `MCP_ERROR_CODES`. +- `paid_call` enforces a configured payment host allowlist (`allowedHosts`, `allowedPaymentHosts`, env `MCP_ALLOWED_HOSTS`) before contacting the network. The default configuration pays nobody (`[]`). Unapproved hosts are refused with a structured error naming the host. +- Near-miss host spoofing (e.g. `notstellar.org`, `stellar.org.attacker.com`) is strictly blocked. +- Non-HTTP/HTTPS schemes (e.g. `file:`, `ftp:`, `gopher:`) are rejected. +- `bazaar_search` blocks requests to private, loopback, link-local, and cloud metadata addresses (e.g. `127.0.0.1`, `169.254.169.254`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `::1`). +- Post-DNS resolution validation prevents DNS rebinding to internal or private IP addresses. +- Redirects are validated manually, refusing redirects leading to forbidden or private destinations. diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index c9a76967..51d09f13 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -4,6 +4,9 @@ export const MCP_ERROR_CODES = { ERR_EXPIRED_AUTHORISATION: 'ERR_EXPIRED_AUTHORISATION', ERR_MISSING_TRUSTLINE: 'ERR_MISSING_TRUSTLINE', ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', + ERR_HOST_NOT_ALLOWED: 'ERR_HOST_NOT_ALLOWED', + ERR_PRIVATE_ADDRESS_REFUSED: 'ERR_PRIVATE_ADDRESS_REFUSED', + ERR_INVALID_URL: 'ERR_INVALID_URL', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', // Refused before any request was made, for two different reasons: diff --git a/src/mcp/security.ts b/src/mcp/security.ts new file mode 100644 index 00000000..052795b3 --- /dev/null +++ b/src/mcp/security.ts @@ -0,0 +1,271 @@ +import dns from 'dns'; +import { McpErrorCode } from './codes.js'; + +export interface SafeUrlValidationOptions { + allowedHosts?: string[]; + requireAllowlist?: boolean; + dnsLookup?: (hostname: string) => Promise; +} + +export type SafeUrlValidationResult = + | { ok: true; url: URL } + | { ok: false; code: McpErrorCode; reason: string }; + +/** + * Checks if a target host matches an allow-list of hosts/domains. + * + * Supports exact domain matches (e.g. 'stellar.org') and wildcard domains (e.g. '*.stellar.org'). + * Prevents near-miss spoofing (e.g. 'notstellar.org' or 'stellar.org.attacker.com'). + */ +export function isHostAllowed(targetHost: string, allowedHosts?: string[]): boolean { + if (!allowedHosts || allowedHosts.length === 0) return false; + if (!targetHost) return false; + + const cleanTarget = targetHost.toLowerCase().trim().replace(/:\d+$/, ''); + + return allowedHosts.some((allowed) => { + const cleanAllowed = allowed + .toLowerCase() + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/:\d+$/, ''); + + if (!cleanAllowed) return false; + + if (cleanAllowed.startsWith('*.')) { + const baseDomain = cleanAllowed.slice(2); + return cleanTarget === baseDomain || cleanTarget.endsWith('.' + baseDomain); + } + + return cleanTarget === cleanAllowed; + }); +} + +/** + * Checks whether an IP address is private, loopback, link-local, or cloud metadata. + * + * Covers: + * - 127.0.0.0/8 (Loopback) + * - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (Private networks RFC 1918) + * - 169.254.0.0/16 (Link-local & AWS/GCP/Azure Cloud Metadata) + * - 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, 198.51.100.0/24, 203.0.113.0/24 + * - 224.0.0.0/4 (Multicast), 240.0.0.0/4 (Reserved / Broadcast) + * - ::1 (IPv6 Loopback), :: (Unspecified), fe80::/10 (Link-local), fc00::/7 (ULA) + * - IPv4-mapped IPv6 (::ffff:x.x.x.x) + */ +export function isPrivateIp(ipAddress: string): boolean { + let ip = ipAddress.trim(); + + // Strip IPv4-mapped IPv6 prefix + if (ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + // IPv6 checks + if (ip === '::1' || ip === '::') return true; + if (/^fe[89ab][0-9a-f]:/i.test(ip)) return true; // Link-local + if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true; // Unique local address (ULA) + + // IPv4 checks + const parts = ip.split('.').map(Number); + if (parts.length === 4 && parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) { + const [p0, p1] = parts; + if (p0 === 0) return true; // Current network + if (p0 === 10) return true; // 10.0.0.0/8 + if (p0 === 127) return true; // 127.0.0.0/8 Loopback + if (p0 === 169 && p1 === 254) return true; // 169.254.0.0/16 Link-local / Cloud Metadata + if (p0 === 172 && p1 >= 16 && p1 <= 31) return true; // 172.16.0.0/12 + if (p0 === 192 && p1 === 168) return true; // 192.168.0.0/16 + if (p0 === 100 && p1 >= 64 && p1 <= 127) return true; // Carrier-grade NAT + if (p0 >= 224) return true; // Multicast & Reserved + } + + return false; +} + +/** + * Checks if a hostname matches private host patterns (e.g. localhost, .internal, etc.). + */ +export function isPrivateHostname(hostname: string): boolean { + const host = hostname.toLowerCase().trim(); + if (host === 'localhost') return true; + if ( + host.endsWith('.localhost') || + host.endsWith('.local') || + host.endsWith('.internal') || + host.endsWith('.lan') || + host.endsWith('.localdomain') + ) { + return true; + } + return false; +} + +/** + * Resolves a hostname to a list of IP addresses using DNS. + */ +export async function resolveHostnameAddresses( + hostname: string, + customDnsLookup?: (hostname: string) => Promise +): Promise { + if (customDnsLookup) { + return customDnsLookup(hostname); + } + + try { + const results = await dns.promises.lookup(hostname, { all: true }); + return results.map((r) => r.address); + } catch (err: any) { + // If it's already an IP or resolution fails + return []; + } +} + +/** + * Validates a URL against protocol schemes, allow-lists, and SSRF restrictions + * (including post-DNS resolution check for private, loopback, link-local, and metadata IPs). + */ +export async function validateSafeUrl( + urlStr: string, + options: SafeUrlValidationOptions +): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(urlStr); + } catch { + return { + ok: false, + code: 'ERR_INVALID_URL', + reason: `Malformed URL: "${urlStr}".`, + }; + } + + // 1. Check scheme + const proto = parsedUrl.protocol.toLowerCase(); + if (proto !== 'http:' && proto !== 'https:') { + return { + ok: false, + code: 'ERR_INVALID_URL', + reason: `Unsupported URL protocol: "${parsedUrl.protocol}". Only HTTP and HTTPS are permitted.`, + }; + } + + const hostname = parsedUrl.hostname; + + // 2. Enforce allow-list if required or if configured + if (options.requireAllowlist || (options.allowedHosts && options.allowedHosts.length > 0)) { + if (!isHostAllowed(hostname, options.allowedHosts)) { + return { + ok: false, + code: 'ERR_HOST_NOT_ALLOWED', + reason: `Host "${hostname}" is not on the allowed host list.`, + }; + } + } + + // 3. Check for literal private hostnames + if (isPrivateHostname(hostname)) { + return { + ok: false, + code: 'ERR_PRIVATE_ADDRESS_REFUSED', + reason: `Host "${hostname}" is a private or local address and is refused.`, + }; + } + + // 4. Check for literal private IP + if (isPrivateIp(hostname)) { + return { + ok: false, + code: 'ERR_PRIVATE_ADDRESS_REFUSED', + reason: `Address "${hostname}" is a private, loopback, or metadata address and is refused.`, + }; + } + + // 5. Post-DNS resolution check: Resolve hostname and check all resolved IPs + const resolvedIps = await resolveHostnameAddresses(hostname, options.dnsLookup); + for (const ip of resolvedIps) { + if (isPrivateIp(ip)) { + return { + ok: false, + code: 'ERR_PRIVATE_ADDRESS_REFUSED', + reason: `Host "${hostname}" resolves to private address "${ip}". Access is refused.`, + }; + } + } + + return { ok: true, url: parsedUrl }; +} + +export interface SafeFetchOptions { + allowedHosts?: string[]; + requireAllowlist?: boolean; + dnsLookup?: (hostname: string) => Promise; + timeoutMs?: number; + fetchFn?: typeof fetch; + maxRedirects?: number; +} + +/** + * Performs a fetch request while validating all URL redirects against SSRF constraints + * and allow-lists, ensuring redirects do not lead to private or unapproved destinations. + */ +export async function safeFetchWithRedirects( + initialUrl: string, + options: SafeFetchOptions = {} +): Promise { + const fetchFn = options.fetchFn || fetch; + const maxRedirects = options.maxRedirects ?? 5; + const timeoutMs = options.timeoutMs ?? 10_000; + + let currentUrl = initialUrl; + let redirectsCount = 0; + + while (redirectsCount <= maxRedirects) { + const validation = await validateSafeUrl(currentUrl, { + allowedHosts: options.allowedHosts, + requireAllowlist: options.requireAllowlist, + dnsLookup: options.dnsLookup, + }); + + if (!validation.ok) { + const err = new Error(validation.reason) as any; + err.code = validation.code; + throw err; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let res: Response; + try { + res = await fetchFn(currentUrl, { + redirect: 'manual', + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + + // Check for HTTP redirect + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get('location'); + if (!location) { + throw Object.assign(new Error('Redirect missing Location header'), { code: 'ERR_SERVICE_UNREACHABLE' }); + } + + redirectsCount++; + if (redirectsCount > maxRedirects) { + throw Object.assign(new Error('Too many redirects'), { code: 'ERR_SERVICE_UNREACHABLE' }); + } + + // Resolve relative redirect against current URL + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + + return res; + } + + throw Object.assign(new Error('Too many redirects'), { code: 'ERR_SERVICE_UNREACHABLE' }); +}