From 9a2990d0f2216ba23e02508a48d2c405b6d0bb40 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 15:29:57 +0530 Subject: [PATCH] feat(mcp): enforce URL allowlist and SSRF defense on search and paid call (#404, #407) --- .../mcp-url-allowlist-and-ssrf-defense.md | 12 + examples/mcp-server/run.ts | 7 +- src/mcp/codes.ts | 3 + src/mcp/security.ts | 271 +++++++++++++++ src/mcp/server.ts | 151 +++++++-- tests/mcp.test.ts | 319 ++++++++++++++++-- 6 files changed, 705 insertions(+), 58 deletions(-) create mode 100644 .changeset/mcp-url-allowlist-and-ssrf-defense.md create mode 100644 src/mcp/security.ts 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/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 2b971525..d05b1cf5 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -33,8 +33,13 @@ async function main() { ] }); + const allowedHosts = process.env.MCP_ALLOWED_HOSTS + ? process.env.MCP_ALLOWED_HOSTS.split(',').map((s) => s.trim()).filter(Boolean) + : []; + const server = new BazaarMcpServer({ - fetchWithPayment + fetchWithPayment, + allowedHosts, }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..04bcbb14 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', ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' 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' }); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..b8393665 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,17 +3,39 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; +import { isHostAllowed, safeFetchWithRedirects } from './security.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; + allowedHosts?: string[]; + allowedPaymentHosts?: string[]; + allowedSearchHosts?: string[]; + dnsLookup?: (hostname: string) => Promise; + searchFetch?: typeof fetch; } export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; + public readonly allowedPaymentHosts: string[]; + public readonly allowedSearchHosts?: string[]; + private dnsLookup?: (hostname: string) => Promise; + private searchFetch?: typeof fetch; constructor(config: McpServerConfig) { this.fetchWithPayment = config.fetchWithPayment; + + // The allow-list must come from configuration, not from tool arguments. + // By default, the payment allow-list defaults to empty (paying nobody). + const envHosts = process.env.MCP_ALLOWED_HOSTS + ? process.env.MCP_ALLOWED_HOSTS.split(',').map((s) => s.trim()).filter(Boolean) + : undefined; + + this.allowedPaymentHosts = config.allowedPaymentHosts ?? config.allowedHosts ?? envHosts ?? []; + this.allowedSearchHosts = config.allowedSearchHosts ?? config.allowedHosts ?? envHosts; + this.dnsLookup = config.dnsLookup; + this.searchFetch = config.searchFetch; + this.server = new Server( { name: 'bazaar-mcp-server', @@ -73,61 +95,125 @@ export class BazaarMcpServer { this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'bazaar_search') { - const url = String(request.params.arguments?.url); + const rawUrl = request.params.arguments?.url; + if (!rawUrl || typeof rawUrl !== 'string') { + return this.createErrorResponse('ERR_INVALID_URL', 'URL argument is required and must be a valid string.'); + } + + const url = String(rawUrl).trim(); const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; - + + let parsed: URL; try { - const endpoint = query - ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` - : `${url.replace(/\/$/, '')}/discovery/resources`; - - const res = await fetchWithTimeout(endpoint); - + parsed = new URL(url); + } catch { + return this.createErrorResponse('ERR_INVALID_URL', `Malformed URL: "${url}".`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return this.createErrorResponse( + 'ERR_INVALID_URL', + `Unsupported URL protocol: "${parsed.protocol}". Only HTTP and HTTPS are permitted.` + ); + } + + const endpoint = query + ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` + : `${url.replace(/\/$/, '')}/discovery/resources`; + + try { + const res = await safeFetchWithRedirects(endpoint, { + allowedHosts: this.allowedSearchHosts, + dnsLookup: this.dnsLookup, + fetchFn: this.searchFetch || fetchWithTimeout, + timeoutMs: 10_000, + }); + if (!res.ok) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); } - + const data = await res.json(); - - const hasResources = (d: unknown): d is { resources: unknown[] } => - typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); + + const hasResources = (d: unknown): d is { resources: unknown[] } => + typeof d === 'object' && + d !== null && + 'resources' in d && + Array.isArray((d as Record).resources); if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); } - + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], }; } catch (e: any) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); + if (e.code && MCP_ERROR_CODES[e.code as McpErrorCode]) { + return this.createErrorResponse(e.code as McpErrorCode, e.message); + } + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message || String(e)); } } if (request.params.name === 'paid_call') { - const url = String(request.params.arguments?.url); + const rawUrl = request.params.arguments?.url; + if (!rawUrl || typeof rawUrl !== 'string') { + return this.createErrorResponse('ERR_INVALID_URL', 'URL argument is required and must be a valid string.'); + } + + const url = String(rawUrl).trim(); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return this.createErrorResponse('ERR_INVALID_URL', `Malformed URL: "${url}".`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return this.createErrorResponse( + 'ERR_INVALID_URL', + `Unsupported URL protocol: "${parsed.protocol}". Only HTTP and HTTPS are permitted.` + ); + } + + const host = parsed.hostname; + + // Refuse before any network call if not allow-listed + if (!isHostAllowed(host, this.allowedPaymentHosts)) { + return this.createErrorResponse( + 'ERR_HOST_NOT_ALLOWED', + `Host "${host}" is not on the allowed payment host list.` + ); + } + try { const res = await this.fetchWithPayment(url, { method }); - + if (!res.ok) { if (res.status === 402) { return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); } return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } - + const text = await res.text(); - let parsed; + let parsedBody; try { - parsed = JSON.parse(text); + parsedBody = JSON.parse(text); } catch { - parsed = text; + parsedBody = text; } - + return { - content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], + content: [ + { + type: 'text', + text: typeof parsedBody === 'string' ? parsedBody : JSON.stringify(parsedBody, null, 2), + }, + ], }; } catch (e: any) { return this.handlePaidCallError(e); @@ -140,15 +226,18 @@ export class BazaarMcpServer { private handlePaidCallError(e: any) { const msg = String(e.message || e).toLowerCase(); - + if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { - return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + return this.createErrorResponse( + 'ERR_EXPIRED_AUTHORISATION', + 'The transaction signature expired before it could be submitted.' + ); } - + if (msg.includes('trustline') || msg.includes('op_no_trust')) { return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); } - + if (msg.includes('insufficient') || msg.includes('underfunded')) { return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); } @@ -156,12 +245,14 @@ export class BazaarMcpServer { if (msg.includes('fetch') || msg.includes('network')) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); } - - return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + + return this.createErrorResponse( + 'ERR_INTERNAL_FAILURE', + e.message || 'An unknown error occurred during the paid call.' + ); } private createErrorResponse(code: McpErrorCode, reason: string) { - // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..23e4952f 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -4,28 +4,30 @@ import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; describe('BazaarMcpServer', () => { it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const mockFetch = vi.fn().mockRejectedValue( + new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125') + ); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['example.com'], + }); - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. let handler: any = null; (server as any).server.setRequestHandler = (schema: any, fn: any) => { if (schema === CallToolRequestSchema) { handler = fn; } }; - - // Re-setup handlers to capture the handler + (server as any).setupHandlers(); const response = await handler({ params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); @@ -34,8 +36,13 @@ describe('BazaarMcpServer', () => { }); it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const mockFetch = vi.fn().mockRejectedValue( + new Error('op_no_trust: The account does not have a trustline for this asset') + ); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['example.com'], + }); let handler: any = null; (server as any).server.setRequestHandler = (schema: any, fn: any) => { @@ -49,9 +56,9 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); @@ -63,10 +70,13 @@ describe('BazaarMcpServer', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 402, - text: async () => 'Payment Required' + text: async () => 'Payment Required', + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['example.com'], }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); let handler: any = null; (server as any).server.setRequestHandler = (schema: any, fn: any) => { @@ -80,24 +90,27 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); const content = JSON.parse(response.content[0].text); expect(content.code).toBe('ERR_PAYMENT_REJECTED'); }); - + it('should return successfully on a successful call', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) + text: async () => JSON.stringify({ success: true, data: 'hello' }), + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['example.com'], }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); let handler: any = null; (server as any).server.setRequestHandler = (schema: any, fn: any) => { @@ -111,13 +124,265 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBeFalsy(); const parsedText = JSON.parse(response.content[0].text); expect(parsedText.success).toBe(true); }); + + describe('Issue #404: paid_call host allow-list', () => { + it('should refuse payment when no allow-list is configured (default pays nobody)', async () => { + const mockFetch = vi.fn(); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { url: 'https://bazaar.stellar.org/api/quote' }, + }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_HOST_NOT_ALLOWED'); + expect(content.reason).toContain('bazaar.stellar.org'); + expect(content.reason).toContain('not on the allowed payment host list'); + // Must not contact network at all + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should allow payment to an allow-listed host', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ paid: true }), + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['bazaar.stellar.org'], + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { + name: 'paid_call', + arguments: { url: 'https://bazaar.stellar.org/api/quote' }, + }, + }); + + expect(response.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledWith('https://bazaar.stellar.org/api/quote', { method: 'GET' }); + }); + + it('should refuse near-miss spoofing of an allowed host', async () => { + const mockFetch = vi.fn(); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['stellar.org'], + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const nearMisses = [ + 'https://notstellar.org/api', + 'https://stellar.org.attacker.com/api', + 'https://fakestellar.org/api', + 'https://sub.stellar.org/api', // without wildcard, exact only + ]; + + for (const targetUrl of nearMisses) { + const response = await handler({ + params: { name: 'paid_call', arguments: { url: targetUrl } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_HOST_NOT_ALLOWED'); + expect(mockFetch).not.toHaveBeenCalled(); + } + }); + + it('should reject non-HTTP schemes for paid_call', async () => { + const mockFetch = vi.fn(); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + allowedHosts: ['stellar.org'], + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { name: 'paid_call', arguments: { url: 'file:///etc/passwd' } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_INVALID_URL'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('Issue #407: bazaar_search SSRF defense', () => { + it('should reject non-HTTP schemes', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const invalidSchemes = ['file:///etc/shadow', 'gopher://127.0.0.1:70', 'ftp://ftp.stellar.org']; + + for (const url of invalidSchemes) { + const response = await handler({ + params: { name: 'bazaar_search', arguments: { url } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_INVALID_URL'); + expect(content.reason).toContain('Only HTTP and HTTPS are permitted'); + } + }); + + it('should refuse private, loopback, link-local, and cloud metadata literal IP addresses', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const privateTargets = [ + 'http://127.0.0.1:8000', + 'http://localhost:3000', + 'http://169.254.169.254/latest/meta-data', + 'http://10.0.0.5:8080', + 'http://172.16.0.10:8080', + 'http://192.168.1.1', + ]; + + for (const url of privateTargets) { + const response = await handler({ + params: { name: 'bazaar_search', arguments: { url } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PRIVATE_ADDRESS_REFUSED'); + } + }); + + it('should refuse hostnames that resolve to private addresses (post-DNS check)', async () => { + // Mock DNS lookup resolving a public-looking domain to a loopback/private IP + const mockDnsLookup = vi.fn().mockImplementation(async (host: string) => { + if (host === 'evil-rebind.com') { + return ['127.0.0.1']; + } + if (host === 'cloud-metadata-spoof.com') { + return ['169.254.169.254']; + } + return ['93.184.216.34']; // public IP + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + dnsLookup: mockDnsLookup, + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response1 = await handler({ + params: { name: 'bazaar_search', arguments: { url: 'http://evil-rebind.com' } }, + }); + expect(response1.isError).toBe(true); + const content1 = JSON.parse(response1.content[0].text); + expect(content1.code).toBe('ERR_PRIVATE_ADDRESS_REFUSED'); + expect(content1.reason).toContain('resolves to private address'); + + const response2 = await handler({ + params: { name: 'bazaar_search', arguments: { url: 'http://cloud-metadata-spoof.com' } }, + }); + expect(response2.isError).toBe(true); + const content2 = JSON.parse(response2.content[0].text); + expect(content2.code).toBe('ERR_PRIVATE_ADDRESS_REFUSED'); + }); + + it('should refuse redirects leading to forbidden or private addresses', async () => { + // First response redirects to 169.254.169.254 + const mockSearchFetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes('innocent.com')) { + return { + status: 302, + headers: { + get: (header: string) => { + if (header.toLowerCase() === 'location') return 'http://169.254.169.254/latest/meta-data'; + return null; + }, + }, + }; + } + return { + status: 200, + ok: true, + json: async () => ({ resources: ['ok'] }), + }; + }); + + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + searchFetch: mockSearchFetch, + dnsLookup: async () => ['93.184.216.34'], // public IP for innocent.com + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { name: 'bazaar_search', arguments: { url: 'http://innocent.com' } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PRIVATE_ADDRESS_REFUSED'); + expect(content.reason).toContain('169.254.169.254'); + }); + }); });