Skip to content
Open
164 changes: 164 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,160 @@ 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]/'],
['IPv4-translated IPv6 loopback', 'http://[::ffff:0:7f00:1]/'],
['NAT64 loopback', 'http://[64:ff9b::7f00:1]/'],
['NAT64 cloud metadata', 'http://[64:ff9b::a9fe:a9fe]/'],
['NAT64 local-use prefix', 'http://[64:ff9b:1::1]/'],
['6to4 loopback', 'http://[2002:7f00:1::]/'],
['6to4 cloud metadata', 'http://[2002:a9fe:a9fe::]/'],
['IPv6 unique-local', 'http://[fc00::1]/'],
['IPv6 link-local', 'http://[fe80::1]/'],
['IPv6 site-local', 'http://[fec0::1]/'],
['IPv6 discard-only 100::/64', 'http://[100::1]/'],
['IPv6 Teredo 2001::/32', 'http://[2001::1]/'],
// Everything outside global unicast (2000::/3) is refused, so reserved
// space cannot be reached just because it is not individually listed.
['IPv6 reserved 4000::/3', 'http://[4000::1]/'],
['IPv6 reserved 8000::/2', 'http://[8000::1]/'],
['IPv6 reserved 1000::/4', 'http://[1000::1]/'],
['IPv6 reserved 0200::/7', 'http://[200::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