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
147 changes: 147 additions & 0 deletions src/everything/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ import {
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';
import { registerSimulateResearchQueryTool } from '../tools/simulate-research-query.js';
import { lookup } from 'node:dns/promises';

// Mock DNS resolution so the gzip tool's hostname SSRF path can be exercised
// without real network lookups. Defaults to the real implementation; tests
// override per-call with mockResolvedValueOnce.
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return { ...actual, lookup: vi.fn(actual.lookup) };
});

// Helper to capture registered tool handlers
function createMockServer() {
Expand Down Expand Up @@ -1217,5 +1226,143 @@ describe('Tools', () => {
handler!({ name: 'test.gz', data: 'ftp://example.com/file.txt', outputType: 'resource' })
).rejects.toThrow('Unsupported URL protocol');
});

// SSRF protection: the tool must refuse to fetch non-public IP addresses.
// These use IP literals so no DNS resolution (or network) is required.
const blockedHosts: Array<[string, string]> = [
['loopback IPv4', 'http://127.0.0.1/secret'],
['cloud metadata', 'http://169.254.169.254/latest/meta-data/'],
['private 10/8', 'http://10.0.0.1/'],
['private 192.168/16', 'http://192.168.1.1/'],
['private 172.16/12', 'http://172.16.0.1/'],
['unspecified', 'http://0.0.0.0/'],
['carrier-grade NAT 100.64/10', 'http://100.64.0.1/'],
['IPv6 loopback', 'http://[::1]/'],
['IPv4-mapped IPv6 loopback', 'http://[::ffff:127.0.0.1]/'],
['IPv4-compatible IPv6 loopback', 'http://[::127.0.0.1]/'],
];
Comment thread
olaservo marked this conversation as resolved.

for (const [label, url] of blockedHosts) {
it(`should refuse to fetch non-public host (${label})`, async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;

let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);

registerGZipFileAsResourceTool(mockServer);

await expect(
handler!({ name: 'test.gz', data: url, outputType: 'resource' })
).rejects.toThrow(/SSRF protection/);
});
}

it('should re-validate redirects and refuse a public URL that redirects to a blocked IP', async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;

let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);

registerGZipFileAsResourceTool(mockServer);

// First (public) hop responds with a redirect to the cloud-metadata IP.
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
})
);

try {
await expect(
handler!({
name: 'test.gz',
// Public IP literal so the first hop needs no DNS resolution.
data: 'http://93.184.216.34/',
outputType: 'resource',
})
).rejects.toThrow(/SSRF protection/);

// The blocked redirect target must be rejected before any request is
// made to it: only the initial public URL was fetched.
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0][0])).toBe('http://93.184.216.34/');
} finally {
fetchSpy.mockRestore();
}
});

it('should refuse a hostname that resolves to a blocked IP', async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;

let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);

registerGZipFileAsResourceTool(mockServer);

// Hostname (not an IP literal) resolves to the cloud-metadata address.
vi.mocked(lookup).mockResolvedValueOnce([
{ address: '169.254.169.254', family: 4 },
] as any);

await expect(
handler!({
name: 'test.gz',
data: 'http://metadata.internal.example/',
outputType: 'resource',
})
).rejects.toThrow(/SSRF protection/);
});

it('should refuse when any of several resolved addresses is blocked', async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;

let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);

registerGZipFileAsResourceTool(mockServer);

// A public and a private address: the "any blocked" rule must reject.
vi.mocked(lookup).mockResolvedValueOnce([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.5', family: 4 },
] as any);

await expect(
handler!({
name: 'test.gz',
data: 'http://mixed.example/',
outputType: 'resource',
})
).rejects.toThrow(/SSRF protection/);
});
});
});
2 changes: 1 addition & 1 deletion src/everything/docs/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Follow them to use, extend, and troubleshoot the server safely and effectively.

## Constraints & Limitations

- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`
- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`. Requests to loopback, private, link-local, and cloud-metadata IP addresses are always blocked (SSRF protection), including across redirects, regardless of the allowlist.
- Session resources are ephemeral and lost when the session ends
- Sampling requests (`trigger-sampling-request`) require client sampling capability
- Elicitation requests (`trigger-elicitation-request`) require client elicitation capability
Expand Down
1 change: 1 addition & 0 deletions src/everything/docs/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ src/everything
- `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB)
- `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000)
- `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed)
- SSRF protection: loopback, private (RFC1918), link-local, and cloud-metadata IP addresses are always refused (and re-validated on every redirect hop), independent of `GZIP_ALLOWED_DOMAINS`.
- `simulate-research-query.ts`
- Registers a `simulate-research-query` task-based tool that demonstrates the MCP Tasks feature (SEP-1686). Simulates a multi-stage research operation with progress updates. If the query is marked as ambiguous and the client supports elicitation, it pauses mid-execution to request clarification via `elicitation/create`. Uses `server.experimental.tasks.registerToolTask()` with `execution: { taskSupport: "required" }`.
- `trigger-elicitation-request.ts`
Expand Down
Loading
Loading