Skip to content
Closed
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
22 changes: 21 additions & 1 deletion packages/core/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getConsentForOauth } from '../utils/authConsent.js';
import {
createPinnedDispatcher,
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import type { Dispatcher } from 'undici';
import {
generatePKCEParams,
startCallbackServer,
Expand Down Expand Up @@ -111,13 +117,27 @@ export class MCPOAuthProvider {
scope: config.scopes?.join(' ') || '',
};

const parsedRegUrl = new URL(registrationUrl);
if (isLoopbackHost(parsedRegUrl.hostname)) {
throw new Error(
`Client registration blocked: loopback address not allowed: ${registrationUrl}`,
);
}
const resolvedAddrs = await resolveAndValidateDns(registrationUrl);
if (resolvedAddrs.length === 0) {
throw new Error(
`Client registration blocked: private/reserved IP not allowed: ${registrationUrl}`,
);
}
const dispatcher = createPinnedDispatcher(resolvedAddrs[0]);
const response = await fetch(registrationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(registrationRequest),
});
dispatcher,
} as RequestInit & { dispatcher: Dispatcher });

if (!response.ok) {
const errorText = await response.text();
Expand Down
44 changes: 42 additions & 2 deletions packages/core/src/mcp/oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
import type { MCPOAuthConfig } from './oauth-provider.js';
import { getErrorMessage } from '../utils/errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
createPinnedDispatcher,
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import type { Dispatcher } from 'undici';

/**
* Error thrown when the discovered resource metadata does not match the expected resource.
Expand Down Expand Up @@ -97,7 +103,24 @@ export class OAuthUtils {
resourceMetadataUrl: string,
): Promise<OAuthProtectedResourceMetadata | null> {
try {
const response = await fetch(resourceMetadataUrl);
const parsedUrl = new URL(resourceMetadataUrl);
if (isLoopbackHost(parsedUrl.hostname)) {
debugLogger.debug(
`Blocked OAuth metadata fetch to loopback address: ${resourceMetadataUrl}`,
);
return null;
}
const resolvedAddrs = await resolveAndValidateDns(resourceMetadataUrl);
if (resolvedAddrs.length === 0) {
debugLogger.debug(
`Blocked OAuth metadata fetch to private/reserved IP: ${resourceMetadataUrl}`,
);
return null;
}
const dispatcher = createPinnedDispatcher(resolvedAddrs[0]);
const response = await fetch(resourceMetadataUrl, {
dispatcher,
} as RequestInit & { dispatcher: Dispatcher });
if (!response.ok) {
return null;
}
Expand All @@ -121,7 +144,24 @@ export class OAuthUtils {
authServerMetadataUrl: string,
): Promise<OAuthAuthorizationServerMetadata | null> {
try {
const response = await fetch(authServerMetadataUrl);
const parsedUrl = new URL(authServerMetadataUrl);
if (isLoopbackHost(parsedUrl.hostname)) {
debugLogger.debug(
`Blocked OAuth metadata fetch to loopback address: ${authServerMetadataUrl}`,
);
return null;
}
const resolvedAddrs = await resolveAndValidateDns(authServerMetadataUrl);
if (resolvedAddrs.length === 0) {
debugLogger.debug(
`Blocked OAuth metadata fetch to private/reserved IP: ${authServerMetadataUrl}`,
);
return null;
}
const dispatcher = createPinnedDispatcher(resolvedAddrs[0]);
const response = await fetch(authServerMetadataUrl, {
dispatcher,
} as RequestInit & { dispatcher: Dispatcher });
if (!response.ok) {
return null;
}
Expand Down
149 changes: 135 additions & 14 deletions packages/core/src/tools/web-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type ToolConfirmationResponse,
} from '../confirmation-bus/types.js';
import { randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import {
logWebFetchFallbackAttempt,
WebFetchFallbackAttemptEvent,
Expand All @@ -52,21 +53,25 @@ vi.mock('../utils/fetch.js', async (importOriginal) => {
return {
...actual,
fetchWithTimeout: vi.fn(),
isPrivateIp: vi.fn(),
resolveAndValidateDns: vi.fn().mockResolvedValue(['8.8.8.8']),
};
});

vi.mock('node:crypto', () => ({
randomUUID: vi.fn(),
}));

vi.mock('node:dns/promises');

/**
* Helper to mock fetchWithTimeout with URL matching.
*/
const mockFetch = (url: string, response: Partial<Response> | Error) =>
vi
.spyOn(fetchUtils, 'fetchWithTimeout')
.mockImplementation(async (actualUrl) => {
// With the undici Agent dispatcher pattern, the original URL is
// always passed unchanged; DNS pinning happens inside the Agent.
if (actualUrl !== url) {
throw new Error(
`Unexpected fetch URL: expected "${url}", got "${actualUrl}"`,
Expand Down Expand Up @@ -270,6 +275,10 @@ describe('WebFetchTool', () => {

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(lookup).mockResolvedValue([
{ address: '8.8.8.8', family: 4 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
mockConfig = {
Expand Down Expand Up @@ -376,7 +385,9 @@ describe('WebFetchTool', () => {

describe('execute', () => {
it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'response' }] } }],
});
Expand All @@ -400,7 +411,9 @@ describe('WebFetchTool', () => {
});

it('should skip rate-limited URLs but fetch others', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);

const tool = new WebFetchTool(mockConfig, bus);
const params = {
Expand Down Expand Up @@ -439,8 +452,8 @@ describe('WebFetchTool', () => {
});

it('should skip private or local URLs but fetch others and log telemetry', async () => {
vi.mocked(fetchUtils.isPrivateIp).mockImplementation(
(url) => url === 'https://private.com/',
vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation(
async (url) => (url === 'https://private.com/' ? [] : ['8.8.8.8']),
);

const tool = new WebFetchTool(mockConfig, bus);
Expand Down Expand Up @@ -475,7 +488,9 @@ describe('WebFetchTool', () => {
});

it('should fallback to all public URLs if primary fails', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);

// Primary fetch fails
mockGenerateContent.mockRejectedValueOnce(new Error('primary fail'));
Expand Down Expand Up @@ -513,8 +528,8 @@ describe('WebFetchTool', () => {
});

it('should NOT include private URLs in fallback', async () => {
vi.mocked(fetchUtils.isPrivateIp).mockImplementation(
(url) => url === 'https://private.com/',
vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation(
async (url) => (url === 'https://private.com/' ? [] : ['8.8.8.8']),
);

// Primary fetch fails
Expand Down Expand Up @@ -546,7 +561,9 @@ describe('WebFetchTool', () => {
});

it('should return WEB_FETCH_FALLBACK_FAILED on total failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
mockGenerateContent.mockRejectedValue(new Error('primary fail'));
mockFetch('https://public.ip/', new Error('fallback fetch failed'));
const tool = new WebFetchTool(mockConfig, bus);
Expand All @@ -559,7 +576,9 @@ describe('WebFetchTool', () => {
});

it('should log telemetry when falling back due to primary fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
// Mock primary fetch to return empty response, triggering fallback
mockGenerateContent.mockResolvedValueOnce({
candidates: [],
Expand Down Expand Up @@ -591,7 +610,9 @@ describe('WebFetchTool', () => {
describe('execute (fallback)', () => {
beforeEach(() => {
// Force fallback by mocking primary fetch to fail
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
mockGenerateContent.mockResolvedValueOnce({
candidates: [],
});
Expand Down Expand Up @@ -929,7 +950,9 @@ describe('WebFetchTool', () => {
});

it('should execute normally after confirmation approval', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
mockGenerateContent.mockResolvedValue({
candidates: [
{
Expand Down Expand Up @@ -963,7 +986,9 @@ describe('WebFetchTool', () => {
describe('execute (experimental)', () => {
beforeEach(() => {
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
});

it('should perform direct fetch and return text for plain text content', async () => {
Expand All @@ -989,6 +1014,7 @@ describe('WebFetchTool', () => {
'https://example.com/',
expect.any(Number),
expect.objectContaining({
dispatcher: expect.any(Object),
headers: expect.objectContaining({
Accept: expect.stringContaining('text/plain'),
}),
Expand Down Expand Up @@ -1142,7 +1168,7 @@ describe('WebFetchTool', () => {
});

it('should block private IP (experimental)', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([]);
const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool['createInvocation'](
{ url: 'http://localhost' },
Expand All @@ -1158,6 +1184,101 @@ describe('WebFetchTool', () => {
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
});

describe('SSRF guard — DNS hostname-to-private-IP bypass (CVE-class)', () => {
it.each([
{
label: 'localhost (literal name)',
url: 'http://localhost',
// caught by explicit loopback check, no DNS mock needed
dnsPrivate: false,
},
{
label: '127.0.0.1 (IPv4 loopback literal)',
url: 'http://127.0.0.1',
dnsPrivate: false,
},
{
label: '::1 (IPv6 loopback literal)',
url: 'http://[::1]',
dnsPrivate: false,
},
{
label: '0.0.0.0 (unspecified literal)',
url: 'http://0.0.0.0',
dnsPrivate: false,
},
{
label: '127.0.0.1.nip.io (nip.io loopback bypass)',
url: 'http://127.0.0.1.nip.io',
// DNS resolves to 127.0.0.1 — mocked via resolveAndValidateDns
dnsPrivate: true,
},
{
label: '169.254.169.254.nip.io (cloud metadata bypass)',
url: 'http://169.254.169.254.nip.io',
dnsPrivate: true,
},
])('should block $label', async ({ url, dnsPrivate }) => {
if (dnsPrivate) {
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([]);
}
const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool['createInvocation']({ url }, bus);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});

expect(result.error?.type).toBe(
ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
);
expect(result.llmContent).toContain(
'Access to blocked or private host',
);
});

it('should allow a public URL (https://google.com)', async () => {
vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([
'8.8.8.8',
]);
const content = 'Google homepage';
mockFetch('https://google.com/', {
status: 200,
headers: new Headers({ 'content-type': 'text/plain' }),
text: () => Promise.resolve(content),
});

const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool.build({ url: 'https://google.com' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});

expect(result.error).toBeUndefined();
expect(result.llmContent).toBe(content);
});

it('should block nip.io bypass in standard (non-experimental) mode', async () => {
// Override the outer beforeEach that enables experimental mode
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(false);
// Simulate DNS resolving the hostname to a private IP
vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation(
async (url) => (url.includes('127.0.0.1.nip.io') ? [] : ['8.8.8.8']),
);

const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch http://127.0.0.1.nip.io/secret' };
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});

expect(result.error?.type).toBe(
ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
);
expect(result.llmContent).toContain('[Blocked Host]');
});
});

it('should bypass truncation if isContextManagementEnabled is true', async () => {
vi.spyOn(mockConfig, 'isContextManagementEnabled').mockReturnValue(true);
const largeContent = 'a'.repeat(300000); // Larger than MAX_CONTENT_LENGTH (250000)
Expand Down
Loading
Loading