Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 138 additions & 10 deletions packages/core-internal/src/shared/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,156 @@ export function normalizeHeaders(headers: RequestInit['headers'] | undefined): R
return { ...(headers as Record<string, string>) };
}

/** 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<Response> => {
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;
}
};
}

Expand Down
96 changes: 95 additions & 1 deletion packages/core-internal/test/shared/transport.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/
);
});
});
});
Loading