Skip to content
Merged
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
8 changes: 7 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ Percent-encode special characters in usernames or passwords before placing them

| Variable | Required | Default | Description |
|---|---|---|---|
| `SEARXNG_TIMEOUT_MS` | No | `10000` | Maximum time in milliseconds to wait for a SearXNG search response. The request is aborted and a network error is returned if the server does not respond within this window. Invalid, non-positive, or out-of-range values (above `2147483647`) fall back to the default. |
| `SEARXNG_TIMEOUT_MS` | No | `10000` | Maximum time in milliseconds for each SearXNG search attempt, covering response headers, body streaming, decoding, and parsing. The request is aborted and a network error is returned if the server does not complete within this window. Invalid, non-positive, or out-of-range values (above `2147483647`) fall back to the default. |
| `FETCH_TIMEOUT_MS` | No | `10000` | Maximum time in milliseconds to wait for a `web_url_read` fetch. The request is aborted and an error is returned if the server does not respond within this window. |
| `SEARXNG_MAX_RESPONSE_BYTES` | No | `5242880` | Maximum retained bytes read from each SearXNG response: successful search JSON, HTML fallback, `/config`, and `/autocompleter`. It is a per-response admission limit, not an aggregate or concurrency cap. |

`SEARXNG_MAX_RESPONSE_BYTES` accepts only a strict integer after surrounding JavaScript whitespace is trimmed. A leading `+` and leading zeros are accepted; `-0`, other non-positive values, fractions, exponents, suffixes, unsafe integers, and values outside the inclusive `1` through `16777216` range are invalid. Unset or blank uses `5242880` silently. An invalid value uses that default and emits one value-free warning per MCP server.

Search JSON and HTML fallback each receive their own full `SEARXNG_TIMEOUT_MS` deadline, so the fallback deadline is separate and additive; replica failover remains per instance and additive as well. `/config` and `/autocompleter` retain their fixed 5-second deadlines through body consumption. Oversized, stalled, partial, malformed, or read-failed responses do not populate successful caches or health state and follow the existing failure, negative-cache, or empty-array path.

## Tool Schema

Expand Down Expand Up @@ -426,6 +431,7 @@ This combined MCP client configuration shows the supported option groups in one
"AUTH_PASSWORD": "legacy-fallback-password",
"SEARXNG_FANOUT": "false",
"SEARXNG_TIMEOUT_MS": "10000",
"SEARXNG_MAX_RESPONSE_BYTES": "5242880",
"FETCH_TIMEOUT_MS": "10000",
"SEARXNG_LITE_TOOLS": "false",
"SEARXNG_DEFAULT_LANGUAGE": "en",
Expand Down
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ The primary security surface areas are:

## Security Features

### SearXNG Response-Body Limits

`SEARXNG_MAX_RESPONSE_BYTES` bounds the bytes this MCP client retains from each
successful SearXNG search JSON response, HTML fallback, `/config`, and
`/autocompleter` response. It defaults to 5 MiB and accepts only strict
integers from 1 through 16 MiB; an invalid value falls back to the default with
one value-free warning per MCP server. This is a per-response retained-byte
admission limit, not an aggregate or concurrency limit, and it does not change,
deploy, restart, reconfigure, or impose settings on the SearXNG service.

For non-success search responses, the client retains at most the lower of the
configured limit and 64 KiB for a credential-sanitized diagnostic preview,
preserves the HTTP status, and appends the fixed `[Response body truncated]`
marker when that preview is cut short. Non-success `/config` and
`/autocompleter` bodies are canceled rather than disclosed. Search deadlines
cover headers, body streaming, decoding, and parsing; JSON and HTML fallback
deadlines are separate and additive, as are per-instance failover attempts.
The fixed 5-second `/config` and `/autocompleter` deadlines include body
consumption. Oversize, stalled, partial, malformed, and read failures do not
populate successful caches or health state and use the existing failure,
negative-cache, or empty-array behavior.

### SSRF Protection (`web_url_read`)

Private and internal URLs are **blocked by default** in all transport modes. The following are rejected:
Expand Down
134 changes: 134 additions & 0 deletions __tests__/e2e/timeout.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

import { strict as assert } from 'node:assert';
import http from 'node:http';
import type { Socket } from 'node:net';
import { fileURLToPath } from 'node:url';
import {
checkSkipConditions,
INIT_PARAMS,
spawnWithMessagesAsync,
spawnWithMessages,
} from './helpers/spawn-server.js';
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
Expand All @@ -39,10 +41,71 @@ async function startHangingServer(): Promise<{ url: string; close: () => Promise
});
}

/**
* Serves response headers without completing the body. The socket tracking is
* deliberate: a failing RED test must still leave no listener or connection.
*/
async function startHeadersThenStallServer(
handleRequest: (request: http.IncomingMessage, response: http.ServerResponse) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const sockets = new Set<Socket>();
const server = http.createServer(handleRequest);
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
server.listen(0, '127.0.0.1', () => {
const address = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((done) => {
for (const socket of sockets) socket.destroy();
server.close(() => done());
}),
});
});
});
}

// Default fetch timeout in src/search.ts and src/url-reader.ts
const FETCH_TIMEOUT_MS = 10000;
// Allow 3s extra for process startup, JSON parsing, etc.
const TEST_TIMEOUT_MS = FETCH_TIMEOUT_MS + 3000;
const BODY_TIMEOUT_MS = 150;
const BODY_TEST_TIMEOUT_MS = 1500;

function hasToolError(response: any): boolean {
return Boolean(
response?.error
|| response?.result?.isError
|| (response?.result?.content?.[0]?.text ?? '').toLowerCase().includes('error'),
);
}

async function expectBodyDeadline(
start: number,
run: () => Promise<Record<number, any>>,
description: string,
): Promise<void> {
let responses: Record<number, any>;
try {
responses = await run();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`${description}: response headers arrived, but the built client did not apply `
+ `SEARXNG_TIMEOUT_MS=${BODY_TIMEOUT_MS} while consuming the body (${detail})`,
);
}

assert.ok(responses[2], `${description}: expected the tool to return a timeout error response`);
assert.ok(hasToolError(responses[2]), `${description}: expected a timeout error response`);
assert.ok(
Date.now() - start < BODY_TEST_TIMEOUT_MS,
`${description}: expected completion before the outer test deadline`,
);
}

async function runTests() {
console.log('⏱ E2E Testing: AbortController timeout (local hanging server)\n');
Expand Down Expand Up @@ -134,6 +197,77 @@ async function runTests() {
}
}, results);

await testFunction('searxng_web_search keeps its timeout while a JSON body stalls after headers', async () => {
const { url, close } = await startHeadersThenStallServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.flushHeaders();
});

try {
const start = Date.now();
await expectBodyDeadline(
start,
() => spawnWithMessagesAsync(
[
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: INIT_PARAMS },
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'searxng_web_search', arguments: { query: 'headers-first-json' } },
},
],
url,
BODY_TEST_TIMEOUT_MS,
{ SEARXNG_TIMEOUT_MS: String(BODY_TIMEOUT_MS) },
),
'JSON response body deadline',
);
} finally {
await close();
}
}, results);

await testFunction('searxng_web_search gives its HTML fallback a fresh body deadline', async () => {
const { url, close } = await startHeadersThenStallServer((request, response) => {
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
if (requestUrl.searchParams.get('format') === 'json') {
response.writeHead(403, { 'content-type': 'text/plain' });
response.end('fallback');
return;
}
response.writeHead(200, { 'content-type': 'text/html' });
response.flushHeaders();
});

try {
const start = Date.now();
await expectBodyDeadline(
start,
() => spawnWithMessagesAsync(
[
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: INIT_PARAMS },
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'searxng_web_search', arguments: { query: 'headers-first-html' } },
},
],
url,
BODY_TEST_TIMEOUT_MS,
{
SEARXNG_TIMEOUT_MS: String(BODY_TIMEOUT_MS),
SEARXNG_HTML_FALLBACK: 'true',
},
),
'HTML fallback response body deadline',
);
} finally {
await close();
}
}, results);

printTestSummary(results, 'E2E: Timeout');
return results;
}
Expand Down
33 changes: 12 additions & 21 deletions __tests__/helpers/mock-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,25 @@ export function createMockFetch(options: FetchMockOptions = {}) {
throw throwError;
}

return {
ok,
// Give consumers a real, readable body stream. Preserve the helper's
// historical json override for callers that deliberately pass both forms.
const response = new Response(body || (json !== null ? JSON.stringify(json) : ''), {
status,
statusText,
// text() and json() come from one body on a real Response — mirror json
// into text when only json is given so a text-first reader stays consistent.
text: async () => {
if (body) {
return body;
}
if (json !== null) {
return JSON.stringify(json);
}
return '';
},
json: async () => {
});
Object.defineProperty(response, 'ok', { value: ok });
Object.defineProperty(response, 'json', {
value: async () => {
if (json !== null) {
return json;
}
if (body) {
return JSON.parse(body);
}
throw new Error('No JSON content');
}
} as Response;
},
});
return response;
};
}

Expand All @@ -73,13 +67,10 @@ export function createCapturingMockFetch() {
capturedUrl = url.toString();
capturedOptions = options;

return {
ok: true,
return new Response(JSON.stringify({ results: [] }), {
status: 200,
statusText: 'OK',
text: async () => JSON.stringify({ results: [] }),
json: async () => ({ results: [] })
} as Response;
});
};

return {
Expand Down
5 changes: 4 additions & 1 deletion __tests__/integration/mcp-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,10 @@ async function runTests() {
fetchMocker.mock(async (url, _opts) => {
capturedUrl = url as string;
const body = JSON.stringify({ results: [{ title: 'R', url: 'https://x.com', content: 'c', score: 1 }] });
return { ok: true, json: async () => JSON.parse(body), text: async () => body } as any;
return new Response(body, {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
const { client } = await connect();

Expand Down
2 changes: 2 additions & 0 deletions __tests__/run-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { runTests as runVersionTests } from './unit/version.test.js';
import { runTests as runHttpSecurityTests } from './unit/http-security.test.js';
import { runTests as runPackedConsumerTests } from './unit/packed-consumer.test.js';
import { runTests as runDocumentationTests } from './unit/documentation.test.js';
import { runTests as runSearxngResponseTests } from './unit/searxng-response.test.js';
import { runTests as runFuzzTests } from './fuzz/search-params.fuzz.js';
import { runTests as runHttpServerTests } from './integration/http-server.test.js';
import { runTests as runIndexTests } from './integration/index.test.js';
Expand Down Expand Up @@ -76,6 +77,7 @@ const testSuites: TestSuite[] = [
{ name: 'HTTP Security', category: 'unit', run: runHttpSecurityTests },
{ name: 'Packed Consumer Verification', category: 'unit', run: runPackedConsumerTests },
{ name: 'Documentation', category: 'unit', run: runDocumentationTests },
{ name: 'SearXNG Response', category: 'unit', run: runSearxngResponseTests },
{ name: 'Fuzz Properties', category: 'unit', run: runFuzzTests },

// Integration Tests
Expand Down
Loading