Skip to content

Commit ce8f47c

Browse files
committed
security(diagnostics): redact authentication data
Coverage: 96.17% (was 96.21%)
1 parent 337a56d commit ce8f47c

21 files changed

Lines changed: 1139 additions & 59 deletions

.mcp/server.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,21 @@
2121
"name": "SEARXNG_URL",
2222
"description": "URL of your SearXNG instance",
2323
"isRequired": true,
24-
"isSecret": false,
24+
"isSecret": true,
25+
"format": "string"
26+
},
27+
{
28+
"name": "AUTH_USERNAME",
29+
"description": "Legacy global SearXNG Basic Auth username",
30+
"isRequired": false,
31+
"isSecret": true,
32+
"format": "string"
33+
},
34+
{
35+
"name": "AUTH_PASSWORD",
36+
"description": "Legacy global SearXNG Basic Auth password",
37+
"isRequired": false,
38+
"isSecret": true,
2539
"format": "string"
2640
}
2741
]

SECURITY.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,24 @@ Enable `MCP_HTTP_TRUST_PROXY` only when the server is behind a trusted reverse p
133133

134134
SearXNG Basic Auth is supported by embedding credentials in the `SEARXNG_URL` userinfo — see the [Authentication section of CONFIGURATION.md](CONFIGURATION.md#authentication) for the exact format. This is the recommended path because each semicolon-separated instance URL can carry its own credentials. URL userinfo is stripped from outgoing fetch URLs and redacted from logs and errors, and it is redacted from the `config://server-config` resource as well (the host is shown, credentials are not). The `/health` endpoint does not expose `SEARXNG_URL`.
135135

136+
Diagnostic output is sanitized before process output, MCP logging
137+
notifications, JSON-RPC errors, or HTTP diagnostic errors are emitted.
138+
Malformed SearXNG instance values are reported by entry number and
139+
classification without echoing the raw value. The configuration resource and
140+
outgoing requests also omit URL userinfo.
141+
142+
Environment configuration is captured when the process starts. Restart the
143+
server after rotating or changing SearXNG Basic Auth credentials so both
144+
requests and diagnostic redaction use the new values.
145+
136146
Because credentials may be embedded in it, treat the whole `SEARXNG_URL` as a secret: `AUTH_PASSWORD` remains available as a legacy global fallback when a `SEARXNG_URL` entry has no userinfo, and `MCP_HTTP_AUTH_TOKEN`, proxy credentials, and any credentials embedded in `SEARXNG_URL` are secrets. Avoid committing them to source control. Use secret management (Docker secrets, environment injection at runtime, or a secrets manager) in production.
137147

148+
If an older release emitted SearXNG credentials into logs or client-visible
149+
errors, upgrade before further use, rotate the affected credentials, and remove
150+
or restrict access to captured logs and telemetry. For coordinated fixes,
151+
publish the patched runtime and updated MCP registry metadata before making the
152+
advisory public.
153+
138154
## Scope
139155

140156
The following are **in scope** for security reports:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env tsx
2+
3+
import { strict as assert } from "node:assert";
4+
import { fileURLToPath } from "node:url";
5+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7+
import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
8+
import {
9+
createTestResults,
10+
printTestSummary,
11+
testFunction,
12+
} from "../helpers/test-utils.js";
13+
14+
const results = createTestResults();
15+
16+
async function connectCli(
17+
searxngUrl: string,
18+
extraEnv: Record<string, string> = {},
19+
) {
20+
const logs: unknown[] = [];
21+
let stderr = "";
22+
const transport = new StdioClientTransport({
23+
command: process.execPath,
24+
args: ["--import", "tsx", "src/cli.ts"],
25+
cwd: process.cwd(),
26+
env: {
27+
...process.env,
28+
SEARXNG_URL: searxngUrl,
29+
...extraEnv,
30+
} as Record<string, string>,
31+
stderr: "pipe",
32+
});
33+
transport.stderr?.on("data", (chunk) => {
34+
stderr += String(chunk);
35+
});
36+
const client = new Client(
37+
{ name: "diagnostic-security-test", version: "1.0.0" },
38+
{ capabilities: {} },
39+
);
40+
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
41+
logs.push(notification);
42+
});
43+
await client.connect(transport);
44+
await new Promise((resolve) => setTimeout(resolve, 20));
45+
return { client, logs, getStderr: () => stderr };
46+
}
47+
48+
async function runTests() {
49+
console.log("Integration Testing: credential-safe diagnostics\n");
50+
51+
await testFunction("real CLI startup logging removes URL Basic Auth userinfo", async () => {
52+
const markerUrl = "https://cli-user:cli-secret@search.example.com/path";
53+
const { client, logs, getStderr } = await connectCli(markerUrl);
54+
await client.close();
55+
56+
const output = `${JSON.stringify(logs)}\n${getStderr()}`;
57+
assert.ok(!output.includes("cli-user"), output);
58+
assert.ok(!output.includes("cli-secret"), output);
59+
assert.ok(output.includes("https://search.example.com/path"), output);
60+
}, results);
61+
62+
await testFunction("real CLI JSON-RPC errors remove invalid URL credentials", async () => {
63+
const markerUrl = "ftp://rpc-user:rpc-secret@search.example.com/path";
64+
const { client, logs, getStderr } = await connectCli(markerUrl);
65+
let errorText = "";
66+
try {
67+
await client.callTool({
68+
name: "searxng_web_search",
69+
arguments: { query: "test" },
70+
});
71+
assert.fail("Expected invalid protocol error");
72+
} catch (error) {
73+
errorText = error instanceof Error ? `${error.message}\n${error.stack}` : String(error);
74+
}
75+
await new Promise((resolve) => setTimeout(resolve, 20));
76+
await client.close();
77+
78+
const output = `${errorText}\n${JSON.stringify(logs)}\n${getStderr()}`;
79+
assert.ok(!output.includes("rpc-user"), output);
80+
assert.ok(!output.includes("rpc-secret"), output);
81+
assert.ok(output.includes("ftp:"), output);
82+
assert.ok(output.includes("search.example.com"), output);
83+
}, results);
84+
85+
await testFunction("outbound network failures never echo Basic Auth material", async () => {
86+
const markerUrl = "http://network-user:network-secret@127.0.0.1:1";
87+
const { client, logs, getStderr } = await connectCli(markerUrl, {
88+
FETCH_TIMEOUT_MS: "250",
89+
});
90+
let errorText = "";
91+
try {
92+
await client.callTool({
93+
name: "searxng_web_search",
94+
arguments: { query: "test" },
95+
});
96+
assert.fail("Expected network failure");
97+
} catch (error) {
98+
errorText = error instanceof Error ? `${error.message}\n${error.stack}` : String(error);
99+
}
100+
await new Promise((resolve) => setTimeout(resolve, 20));
101+
await client.close();
102+
103+
const output = `${errorText}\n${JSON.stringify(logs)}\n${getStderr()}`;
104+
assert.ok(!output.includes("network-user"), output);
105+
assert.ok(!output.includes("network-secret"), output);
106+
assert.ok(
107+
output.includes("Connection") || output.includes("Network"),
108+
output,
109+
);
110+
}, results);
111+
112+
printTestSummary(results, "Credential-Safe Diagnostics");
113+
return results;
114+
}
115+
116+
if (
117+
process.argv[1] !== undefined
118+
&& fileURLToPath(import.meta.url) === process.argv[1]
119+
) {
120+
runTests().then((testResults) => {
121+
process.exit(testResults.failed > 0 ? 1 : 0);
122+
}).catch(console.error);
123+
}
124+
125+
export { runTests };

__tests__/integration/http-server.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1313
import { createHttpServer } from '../../src/http-server.js';
1414
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
1515
import { EnvManager } from '../helpers/env-utils.js';
16+
import {
17+
initializeDiagnosticSanitizer,
18+
resetDiagnosticSanitizerForTests,
19+
} from '../../src/diagnostic-sanitizer.js';
1620

1721
const results = createTestResults();
1822
const envManager = new EnvManager();
@@ -246,6 +250,42 @@ async function runTests() {
246250
assert.ok(res.headers['mcp-session-id'], 'Expected mcp-session-id header in response');
247251
}, results);
248252

253+
await testFunction('HTTP initialization failures redact response and stderr diagnostics', async () => {
254+
envManager.set(
255+
'SEARXNG_URL',
256+
'https://connect-user:connect-secret@search.example.com',
257+
);
258+
resetDiagnosticSanitizerForTests();
259+
initializeDiagnosticSanitizer();
260+
const app = await createHttpServer(() => {
261+
throw new Error('connect failed for connect-user:connect-secret');
262+
});
263+
let response: request.Response | undefined;
264+
const output = await captureConsoleOutput(async () => {
265+
response = await request(app)
266+
.post('/mcp')
267+
.set('Content-Type', 'application/json')
268+
.set('Accept', 'application/json, text/event-stream')
269+
.send({
270+
jsonrpc: '2.0',
271+
id: 1,
272+
method: 'initialize',
273+
params: {
274+
protocolVersion: '2024-11-05',
275+
capabilities: {},
276+
clientInfo: { name: 'test-client', version: '1.0.0' },
277+
},
278+
});
279+
});
280+
281+
const combined = `${response?.text}\n${output.join('\n')}`;
282+
assert.equal(response?.status, 500);
283+
assert.ok(!combined.includes('connect-user'), combined);
284+
assert.ok(!combined.includes('connect-secret'), combined);
285+
resetDiagnosticSanitizerForTests();
286+
envManager.restore();
287+
}, results);
288+
249289
await testFunction('POST /mcp with stale sessionId and initialize request creates new session', async () => {
250290
const app = await createHttpServer(() => createTestMcpServer());
251291

__tests__/integration/mcp-handlers.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import net from 'node:net';
1414
import { fileURLToPath } from 'node:url';
1515
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1616
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
17+
import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
1718
import { createMcpServer } from '../../src/index.js';
19+
import {
20+
initializeDiagnosticSanitizer,
21+
resetDiagnosticSanitizerForTests,
22+
} from '../../src/diagnostic-sanitizer.js';
1823
import { FetchMocker, createCapturingMockFetch, createMockFetch } from '../helpers/mock-fetch.js';
1924
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
2025

@@ -34,6 +39,22 @@ async function connect() {
3439
return { client, mcpServer };
3540
}
3641

42+
async function connectWithLogs() {
43+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
44+
const mcpServer = createMcpServer();
45+
const client = new Client(
46+
{ name: 'test-client', version: '1.0.0' },
47+
{ capabilities: {} },
48+
);
49+
const logs: unknown[] = [];
50+
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
51+
logs.push(notification);
52+
});
53+
await mcpServer.connect(serverTransport);
54+
await client.connect(clientTransport);
55+
return { client, logs };
56+
}
57+
3758
/** Minimal valid SearXNG JSON response */
3859
const SEARXNG_RESPONSE = JSON.stringify({
3960
results: [
@@ -444,6 +465,37 @@ async function runTests() {
444465
await client.close();
445466
}, results);
446467

468+
await testFunction('tool errors and MCP logs never expose configured Basic Auth material', async () => {
469+
const originalUrl = process.env.SEARXNG_URL;
470+
process.env.SEARXNG_URL = 'ftp://protocol-user:protocol-secret@search.example.com/path';
471+
resetDiagnosticSanitizerForTests();
472+
initializeDiagnosticSanitizer();
473+
const { client, logs } = await connectWithLogs();
474+
let errorText = '';
475+
476+
try {
477+
await client.callTool({
478+
name: 'searxng_web_search',
479+
arguments: { query: 'test' },
480+
});
481+
assert.fail('Expected configuration error');
482+
} catch (error) {
483+
errorText = error instanceof Error ? `${error.message}\n${error.stack}` : String(error);
484+
} finally {
485+
await new Promise(resolve => setTimeout(resolve, 10));
486+
await client.close();
487+
if (originalUrl === undefined) delete process.env.SEARXNG_URL;
488+
else process.env.SEARXNG_URL = originalUrl;
489+
resetDiagnosticSanitizerForTests();
490+
}
491+
492+
const output = `${errorText}\n${JSON.stringify(logs)}`;
493+
assert.ok(!output.includes('protocol-user'), output);
494+
assert.ok(!output.includes('protocol-secret'), output);
495+
assert.ok(output.includes('ftp:'), output);
496+
assert.ok(output.includes('search.example.com'), output);
497+
}, results);
498+
447499
// ── tools/call: web_url_read ─────────────────────────────────────────────────
448500

449501
await testFunction('tools/call web_url_read returns markdown text', async () => {
@@ -748,6 +800,32 @@ async function runTests() {
748800
await client.close();
749801
}, results);
750802

803+
await testFunction('resources/read errors redact credential-bearing URIs', async () => {
804+
const originalUrl = process.env.SEARXNG_URL;
805+
const markerUrl = 'https://resource-user:resource-secret@search.example.com';
806+
process.env.SEARXNG_URL = markerUrl;
807+
resetDiagnosticSanitizerForTests();
808+
initializeDiagnosticSanitizer();
809+
const { client } = await connect();
810+
let output = '';
811+
812+
try {
813+
await client.readResource({ uri: markerUrl });
814+
assert.fail('Expected error was not thrown');
815+
} catch (error) {
816+
output = error instanceof Error ? `${error.message}\n${error.stack}` : String(error);
817+
} finally {
818+
await client.close();
819+
if (originalUrl === undefined) delete process.env.SEARXNG_URL;
820+
else process.env.SEARXNG_URL = originalUrl;
821+
resetDiagnosticSanitizerForTests();
822+
}
823+
824+
assert.ok(!output.includes('resource-user'), output);
825+
assert.ok(!output.includes('resource-secret'), output);
826+
assert.ok(output.includes('search.example.com'), output);
827+
}, results);
828+
751829
printTestSummary(results, 'MCP Handler Dispatch');
752830
return results;
753831
}

__tests__/run-all.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { TestResult } from './helpers/test-utils.js';
1010

1111
// Import all test suites
1212
import { runTests as runLoggingTests } from './unit/logging.test.js';
13+
import { runTests as runDiagnosticSanitizerTests } from './unit/diagnostic-sanitizer.test.js';
14+
import { runTests as runDiagnosticOutputTests } from './unit/diagnostic-output.test.js';
1315
import { runTests as runTypesTests } from './unit/types.test.js';
1416
import { runTests as runCacheTests } from './unit/cache.test.js';
1517
import { runTests as runSearchCacheTests } from './unit/search-cache.test.js';
@@ -31,6 +33,7 @@ import { runTests as runHttpServerTests } from './integration/http-server.test.j
3133
import { runTests as runIndexTests } from './integration/index.test.js';
3234
import { runTests as runMcpHandlersTests } from './integration/mcp-handlers.test.js';
3335
import { runTests as runCliTests } from './integration/cli.test.js';
36+
import { runTests as runDiagnosticSecurityTests } from './integration/diagnostic-security.test.js';
3437

3538
interface TestSuite {
3639
name: string;
@@ -41,6 +44,8 @@ interface TestSuite {
4144
const testSuites: TestSuite[] = [
4245
// Unit Tests
4346
{ name: 'Logging', category: 'unit', run: runLoggingTests },
47+
{ name: 'Diagnostic Sanitizer', category: 'unit', run: runDiagnosticSanitizerTests },
48+
{ name: 'Diagnostic Output', category: 'unit', run: runDiagnosticOutputTests },
4449
{ name: 'Types', category: 'unit', run: runTypesTests },
4550
{ name: 'Cache', category: 'unit', run: runCacheTests },
4651
{ name: 'Search Cache', category: 'unit', run: runSearchCacheTests },
@@ -64,6 +69,7 @@ const testSuites: TestSuite[] = [
6469
{ name: 'Main Index', category: 'integration', run: runIndexTests },
6570
{ name: 'MCP Handlers', category: 'integration', run: runMcpHandlersTests },
6671
{ name: 'CLI', category: 'integration', run: runCliTests },
72+
{ name: 'Credential-Safe Diagnostics', category: 'integration', run: runDiagnosticSecurityTests },
6773
];
6874

6975
async function runAllTests() {

0 commit comments

Comments
 (0)