diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index bf985d4160..422d9674cf 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -64,7 +64,7 @@ export { deserializeMessage, ReadBuffer, serializeMessage, STDIO_DEFAULT_MAX_BUF // Transport types (NOT normalizeHeaders) export type { FetchLike, Transport, TransportSendOptions } from '../../shared/transport'; -export { createFetchWithInit } from '../../shared/transport'; +export { createFetchWithInit, type FetchWithInitOptions, isPrivateOrLoopbackHost, isSafeRedirectTarget } from '../../shared/transport'; export { InMemoryTransport } from '../../util/inMemory'; // URI Template diff --git a/packages/core-internal/src/shared/transport.ts b/packages/core-internal/src/shared/transport.ts index 226f6ab0bd..52d9b35721 100644 --- a/packages/core-internal/src/shared/transport.ts +++ b/packages/core-internal/src/shared/transport.ts @@ -20,28 +20,156 @@ export function normalizeHeaders(headers: RequestInit['headers'] | undefined): R return { ...(headers as Record) }; } +/** Loopback hostnames and IPs */ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** + * Checks if a hostname represents a loopback or internal metadata address. + */ +export function isPrivateOrLoopbackHost(hostname: string): boolean { + if (LOOPBACK_HOSTS.has(hostname.toLowerCase())) return true; + + // IPv4 127.0.0.0/8 + if (/^127(?:\.\d{1,3}){3}$/.test(hostname)) return true; + + // Cloud metadata IPv4 169.254.169.254 (link-local) + if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return true; + + // IPv4 link-local (169.254.0.0/16) + if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname)) return true; + + // IPv4 Private subnets (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + if (/^10(?:\.\d{1,3}){3}$/.test(hostname)) return true; + if (/^192\.168(?:\.\d{1,3}){2}$/.test(hostname)) return true; + const match172 = hostname.match(/^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/); + if (match172 && Number(match172[1]) >= 16 && Number(match172[1]) <= 31) return true; + + return false; +} + +/** + * Validates whether a redirect target URL is safe to follow. + * Prevents SSRF / protocol confusion by rejecting redirection from public endpoints into internal/loopback hosts. + */ +export function isSafeRedirectTarget(sourceUrl: string | URL, targetUrl: string | URL, allowLoopback: boolean = false): boolean { + const src = new URL(String(sourceUrl)); + const tgt = new URL(String(targetUrl), src); + + // If target protocol is not http or https, reject + if (tgt.protocol !== 'http:' && tgt.protocol !== 'https:') { + return false; + } + + if (allowLoopback) { + return true; + } + + const srcIsPrivate = isPrivateOrLoopbackHost(src.hostname); + const tgtIsPrivate = isPrivateOrLoopbackHost(tgt.hostname); + + // If source was already a private/loopback service, allowing redirect to another private service is acceptable + if (srcIsPrivate) { + return true; + } + + // If source was public, NEVER allow redirecting into private/loopback addresses + return !tgtIsPrivate; +} + +export interface FetchWithInitOptions { + baseInit?: RequestInit; + allowLoopbackRedirects?: boolean; + maxRedirects?: number; +} + /** - * Creates a fetch function that includes base `RequestInit` options. - * This ensures requests inherit settings like credentials, mode, headers, etc. from the base init. + * Creates a fetch function that includes base `RequestInit` options and safely handles redirects. + * Protects against SSRF / protocol confusion by validating redirect targets before following them. * * @param baseFetch - The base fetch function to wrap (defaults to global `fetch`) - * @param baseInit - The base `RequestInit` to merge with each request - * @returns A wrapped fetch function that merges base options with call-specific options + * @param optionsOrBaseInit - Options object or base RequestInit + * @returns A wrapped fetch function that handles options and redirect security */ -export function createFetchWithInit(baseFetch: FetchLike = fetch, baseInit?: RequestInit): FetchLike { - if (!baseInit) { +export function createFetchWithInit(baseFetch: FetchLike = fetch, optionsOrBaseInit?: RequestInit | FetchWithInitOptions): FetchLike { + const isOptionsObject = Boolean( + optionsOrBaseInit && + typeof optionsOrBaseInit === 'object' && + ('baseInit' in optionsOrBaseInit || 'allowLoopbackRedirects' in optionsOrBaseInit || 'maxRedirects' in optionsOrBaseInit) + ); + const baseInit: RequestInit | undefined = isOptionsObject + ? (optionsOrBaseInit as FetchWithInitOptions).baseInit + : (optionsOrBaseInit as RequestInit | undefined); + const allowLoopbackRedirects = isOptionsObject ? Boolean((optionsOrBaseInit as FetchWithInitOptions).allowLoopbackRedirects) : false; + const maxRedirects = + isOptionsObject && typeof (optionsOrBaseInit as FetchWithInitOptions).maxRedirects === 'number' + ? (optionsOrBaseInit as FetchWithInitOptions).maxRedirects! + : 5; + + // Fast return if no options/init are provided at all + if (!optionsOrBaseInit) { return baseFetch; } - // Return a wrapped fetch that merges base RequestInit with call-specific init return async (url: string | URL, init?: RequestInit): Promise => { const mergedInit: RequestInit = { ...baseInit, ...init, - // Headers need special handling - merge instead of replace - headers: init?.headers ? { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) } : baseInit.headers + headers: init?.headers ? { ...normalizeHeaders(baseInit?.headers), ...normalizeHeaders(init.headers) } : baseInit?.headers }; - return baseFetch(url, mergedInit); + + // If caller explicitly configured redirect mode (e.g. 'manual' or 'error'), delegate directly to baseFetch + if (mergedInit.redirect && mergedInit.redirect !== 'follow') { + return baseFetch(url, mergedInit); + } + + // Intercept redirects to enforce SSRF security checks + let currentUrl: string | URL = url; + let currentInit: RequestInit = { ...mergedInit, redirect: 'manual' }; + let redirectCount = 0; + + while (true) { + const response = await baseFetch(currentUrl, currentInit); + if (!response) { + return response; + } + + // Check if response is a redirect status (301, 302, 303, 307, 308) + const isRedirect = + response.status >= 301 && + response.status <= 308 && + response.status !== 304 && + response.status !== 305 && + response.status !== 306; + const location = response.headers?.get ? response.headers.get('location') : undefined; + + if (isRedirect && location) { + if (redirectCount >= maxRedirects) { + throw new Error(`Too many redirects (max: ${maxRedirects})`); + } + + if (!isSafeRedirectTarget(currentUrl, location, allowLoopbackRedirects)) { + throw new Error( + `Insecure redirect rejected: redirection to internal/loopback address '${location}' from '${String(currentUrl)}' is prohibited.` + ); + } + + const nextUrl = new URL(location, String(currentUrl)); + redirectCount++; + currentUrl = nextUrl; + + // For 303 See Other, change method to GET and drop body (RFC 7231) + if (response.status === 303) { + currentInit = { + ...currentInit, + method: 'GET', + body: undefined + }; + } + continue; + } + + return response; + } }; } diff --git a/packages/core-internal/test/shared/transport.test.ts b/packages/core-internal/test/shared/transport.test.ts index 2a17ac0643..22dd2fb370 100644 --- a/packages/core-internal/test/shared/transport.test.ts +++ b/packages/core-internal/test/shared/transport.test.ts @@ -1,4 +1,10 @@ -import { createFetchWithInit, type FetchLike, normalizeHeaders } from '../../src/shared/transport'; +import { + createFetchWithInit, + type FetchLike, + normalizeHeaders, + isPrivateOrLoopbackHost, + isSafeRedirectTarget +} from '../../src/shared/transport'; describe('normalizeHeaders', () => { test('returns empty object for undefined', () => { @@ -179,4 +185,92 @@ describe('createFetchWithInit', () => { }) ); }); + + describe('SSRF & Redirect Protection', () => { + test('createFetchWithInit without baseInit still applies SSRF redirect protection when given options', async () => { + const mockFetch: FetchLike = vi.fn().mockResolvedValue( + new Response(null, { + status: 307, + headers: { Location: 'http://127.0.0.1:8080/internal-service' } + }) + ); + + const wrappedFetch = createFetchWithInit(mockFetch, { allowLoopbackRedirects: false }); + await expect(wrappedFetch('https://api.example.com/mcp')).rejects.toThrow( + /Insecure redirect rejected: redirection to internal\/loopback address/ + ); + }); + + test('isPrivateOrLoopbackHost correctly identifies loopback and private ranges', () => { + expect(isPrivateOrLoopbackHost('localhost')).toBe(true); + expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('127.0.1.10')).toBe(true); + expect(isPrivateOrLoopbackHost('::1')).toBe(true); + expect(isPrivateOrLoopbackHost('[::1]')).toBe(true); + expect(isPrivateOrLoopbackHost('169.254.169.254')).toBe(true); + expect(isPrivateOrLoopbackHost('metadata.google.internal')).toBe(true); + expect(isPrivateOrLoopbackHost('10.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('192.168.1.1')).toBe(true); + expect(isPrivateOrLoopbackHost('172.16.0.5')).toBe(true); + expect(isPrivateOrLoopbackHost('172.31.255.255')).toBe(true); + + expect(isPrivateOrLoopbackHost('example.com')).toBe(false); + expect(isPrivateOrLoopbackHost('api.anthropic.com')).toBe(false); + expect(isPrivateOrLoopbackHost('172.32.0.1')).toBe(false); + expect(isPrivateOrLoopbackHost('8.8.8.8')).toBe(false); + }); + + test('isSafeRedirectTarget allows public to public redirects', () => { + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'https://api.example.com/v2/mcp')).toBe(true); + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'https://cdn.other.com/mcp')).toBe(true); + }); + + test('isSafeRedirectTarget rejects public to loopback/private redirects (SSRF)', () => { + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'http://127.0.0.1:8080/secret')).toBe(false); + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'http://localhost:3000/')).toBe(false); + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'http://169.254.169.254/latest/meta-data/')).toBe(false); + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'http://10.0.0.5:8080/')).toBe(false); + }); + + test('isSafeRedirectTarget honors allowLoopback = true', () => { + expect(isSafeRedirectTarget('https://api.example.com/mcp', 'http://127.0.0.1:8080/secret', true)).toBe(true); + }); + + test('wrappedFetch follows safe public redirect seamlessly', async () => { + const mockFetch: FetchLike = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { Location: 'https://api.example.com/target' } + }) + ) + .mockResolvedValueOnce( + new Response('{"jsonrpc":"2.0","result":"ok"}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + const wrappedFetch = createFetchWithInit(mockFetch, {}); + const response = await wrappedFetch('https://api.example.com/initial'); + expect(response.status).toBe(200); + expect(await response.text()).toBe('{"jsonrpc":"2.0","result":"ok"}'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('wrappedFetch rejects redirect to loopback from public endpoint', async () => { + const mockFetch: FetchLike = vi.fn().mockResolvedValue( + new Response(null, { + status: 307, + headers: { Location: 'http://127.0.0.1:8080/internal-service' } + }) + ); + + const wrappedFetch = createFetchWithInit(mockFetch, {}); + await expect(wrappedFetch('https://api.example.com/mcp')).rejects.toThrow( + /Insecure redirect rejected: redirection to internal\/loopback address/ + ); + }); + }); });