Skip to content

Commit bb7439b

Browse files
aborrusoclaude
andcommitted
fix(security): resolve DNS to close SSRF bypass + mandatory HTTP allowlist (v0.4.108)
GHSA-798p-78g2-v556: validateServerUrl only checked the hostname string, so a name resolving to an internal IP (lvh.me -> 127.0.0.1, *.nip.io -> cloud IMDS) bypassed the guard. A string denylist cannot close this class. - Extract isBlockedIp(), shared by literal and resolved-IP guards - Node/axios: SSRF-safe lookup agent resolves, validates every resolved IP and pins the connection (closes DNS-rebinding and redirect-to-internal); maxRedirects: 5 - sparql_query (fetch): pre-resolution check assertHostnameResolvesSafe (HTTPS-only) - HTTP transport: refuse to start without CKAN_ALLOWED_DOMAINS unless CKAN_HTTP_ALLOW_ALL=true (default-deny); stdio stays open; Worker unaffected - 11 new tests; verified end-to-end against a real HTTP deployment Reported by: EchoSkorJjj Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 129ba17 commit bb7439b

10 files changed

Lines changed: 304 additions & 38 deletions

File tree

LOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# LOG
22

3+
## 2026-06-22
4+
5+
### v0.4.108
6+
7+
- Security fix (GHSA-798p-78g2-v556): close DNS-name SSRF bypass — `validateServerUrl` only checked the hostname string, so a name resolving to an internal IP (e.g. `lvh.me``127.0.0.1`, `*.nip.io` → cloud IMDS) bypassed the guard. Added DNS resolution + validation of every resolved IP, with connection pinning via a custom `lookup` agent (closes DNS-rebinding and redirect-to-internal) on the Node/axios path; pre-resolution check on the fetch-based `sparql_query` (HTTPS-only). Extracted `isBlockedIp` shared by literal and resolved-IP guards. `maxRedirects: 5` on CKAN requests.
8+
- Hardening: the network-exposed HTTP transport now refuses to start without `CKAN_ALLOWED_DOMAINS` (default-deny), unless explicitly opted out with `CKAN_HTTP_ALLOW_ALL=true` (logs a warning). stdio stays open. Cloudflare Worker unaffected (CF sandbox already blocks internal addresses).
9+
- 11 new tests (isBlockedIp, SSRF-safe lookup, allowlist gate, DNS-bypass on sparql). Verified end-to-end against a real HTTP deployment.
10+
- Reported by: EchoSkorJjj
11+
312
## 2026-06-18
413

514
### v0.4.107

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"dxt_version": "0.1",
33
"name": "ckan-mcp-server",
4-
"version": "0.4.107",
4+
"version": "0.4.108",
55
"display_name": "CKAN MCP Server",
66
"description": "Explore open data portals based on CKAN (dati.gov.it, data.gov, open.canada.ca, ...)",
77
"long_description": "MCP server for interacting with CKAN-based open data portals. Provides tools for advanced dataset search with Solr syntax, DataStore queries for tabular data analysis, organization and group exploration, and complete metadata access.",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@aborruso/ckan-mcp-server",
3-
"version": "0.4.107",
3+
"version": "0.4.108",
44
"mcpName": "io.github.aborruso/ckan-mcp-server",
55
"description": "MCP server for interacting with CKAN open data portals",
66
"main": "dist/index.js",

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { registerAllPrompts } from "./prompts/index.js";
1919
export function createServer(): McpServer {
2020
return new McpServer({
2121
name: "ckan-mcp-server",
22-
version: "0.4.107"
22+
version: "0.4.108"
2323
});
2424
}
2525

src/tools/sparql.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { z } from "zod";
66
import { ResponseFormatSchema, ResponseFormat, CHARACTER_LIMIT } from "../types.js";
77
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
88
import { getSparqlConfig } from "../utils/portal-config.js";
9-
import { validateServerUrl } from "../utils/http.js";
9+
import { validateServerUrl, assertHostnameResolvesSafe } from "../utils/http.js";
1010

1111
const DEFAULT_LIMIT = 25;
1212
const MAX_LIMIT = 1000;
@@ -50,6 +50,8 @@ export async function querySparqlEndpoint(endpointUrl: string, query: string): P
5050
if (url.protocol !== "https:") {
5151
throw new Error("Only HTTPS endpoints are allowed");
5252
}
53+
// SSRF: block hostnames that resolve to private/internal addresses (DNS-name bypass)
54+
await assertHostnameResolvesSafe(url.hostname);
5355

5456
const sparqlConfig = getSparqlConfig(endpointUrl);
5557
const method = sparqlConfig?.method ?? "POST";

src/transport/http.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
import express from "express";
66
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
77
import { createServer, registerAll } from "../server.js";
8+
import { assertHttpAllowlistConfigured } from "../utils/http.js";
89

910
export async function runHTTP() {
11+
// Network-exposed transport: require a domain allowlist (fail-fast) unless
12+
// explicitly opted out via CKAN_HTTP_ALLOW_ALL=true.
13+
assertHttpAllowlistConfigured();
14+
1015
const app = express();
1116
app.use(express.json());
1217

src/utils/http.ts

Lines changed: 184 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,46 @@ async function decodePossiblyCompressed(
238238
}
239239
}
240240

241+
/**
242+
* Returns true if an IP address (IPv4 or IPv6 string) is in a private/internal/special range.
243+
* Shared by the synchronous literal guard (`validateServerUrl`) and the DNS-resolution
244+
* guard (`createSsrfSafeLookup` / `assertHostnameResolvesSafe`), so a hostname that
245+
* *resolves* to an internal address is blocked, not just an internal IP literal.
246+
*/
247+
export function isBlockedIp(ip: string): boolean {
248+
const v = ip.toLowerCase().trim();
249+
250+
// IPv6 (any address containing a colon)
251+
if (v.includes(':')) {
252+
if (v === '::1' || v === '::') return true; // loopback / unspecified
253+
if (v.startsWith('fc') || v.startsWith('fd')) return true; // fc00::/7 unique local
254+
if (v.startsWith('fe80')) return true; // fe80::/10 link-local
255+
if (v.startsWith('::ffff:')) return true; // IPv4-mapped IPv6
256+
return false;
257+
}
258+
259+
// IPv4 dotted-decimal
260+
const m = v.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
261+
if (!m) return false;
262+
const o1 = Number(m[1]);
263+
const o2 = Number(m[2]);
264+
return (
265+
o1 === 0 || // 0.0.0.0/8
266+
o1 === 10 || // 10.0.0.0/8 private
267+
o1 === 127 || // 127.0.0.0/8 loopback
268+
(o1 === 100 && o2 >= 64 && o2 <= 127) || // 100.64.0.0/10 shared
269+
(o1 === 169 && o2 === 254) || // 169.254.0.0/16 link-local / cloud metadata
270+
(o1 === 172 && o2 >= 16 && o2 <= 31) || // 172.16.0.0/12 private
271+
(o1 === 192 && o2 === 168) || // 192.168.0.0/16 private
272+
o1 === 255 // broadcast
273+
);
274+
}
275+
241276
/**
242277
* Validate that a server URL is safe to request (SSRF prevention).
243-
* Blocks non-HTTP/S protocols and private/internal IP ranges.
278+
* Blocks non-HTTP/S protocols and private/internal IP *literals*.
279+
* Hostnames that resolve to internal IPs are blocked later, at connection time,
280+
* by the DNS-resolution guards (see `createSsrfSafeLookup` / `assertHostnameResolvesSafe`).
244281
*/
245282
export function validateServerUrl(serverUrl: string): void {
246283
let parsed: URL;
@@ -265,38 +302,14 @@ export function validateServerUrl(serverUrl: string): void {
265302
throw new Error(`Access to "${hostname}" is not allowed.`);
266303
}
267304

268-
// Block IPv4 private/special ranges
269-
const ipv4 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
270-
if (ipv4) {
271-
const [o1, o2] = ipv4.slice(1).map(Number);
272-
const blocked =
273-
o1 === 0 || // 0.0.0.0/8
274-
o1 === 10 || // 10.0.0.0/8 private
275-
o1 === 127 || // 127.0.0.0/8 loopback
276-
(o1 === 100 && o2 >= 64 && o2 <= 127) || // 100.64.0.0/10 shared
277-
(o1 === 169 && o2 === 254) || // 169.254.0.0/16 link-local / AWS metadata
278-
(o1 === 172 && o2 >= 16 && o2 <= 31) || // 172.16.0.0/12 private
279-
(o1 === 192 && o2 === 168) || // 192.168.0.0/16 private
280-
o1 === 255; // broadcast
281-
if (blocked) {
282-
throw new Error(`Access to private/internal IP addresses is not allowed.`);
283-
}
305+
// Block IPv4 private/special literals
306+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) && isBlockedIp(hostname)) {
307+
throw new Error(`Access to private/internal IP addresses is not allowed.`);
284308
}
285309

286-
// Block IPv6 private/loopback
287-
if (hostname.startsWith('[')) {
288-
const ipv6 = hostname.slice(1, -1);
289-
const lower = ipv6.toLowerCase();
290-
const blockedIpv6 =
291-
lower === '::1' || // loopback
292-
lower === '::' || // unspecified
293-
lower.startsWith('fc') || // fc00::/7 unique local
294-
lower.startsWith('fd') || // fd00::/8 unique local
295-
lower.startsWith('fe80') || // fe80::/10 link-local
296-
lower.startsWith('::ffff:'); // IPv4-mapped
297-
if (blockedIpv6) {
298-
throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
299-
}
310+
// Block IPv6 private/loopback literals (URL hostname keeps the brackets)
311+
if (hostname.startsWith('[') && isBlockedIp(hostname.slice(1, -1))) {
312+
throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
300313
}
301314

302315
// Optional domain allowlist: CKAN_ALLOWED_DOMAINS=domain1.com,domain2.org
@@ -307,6 +320,141 @@ export function validateServerUrl(serverUrl: string): void {
307320
}
308321
}
309322

323+
/**
324+
* Refuse to run the network-exposed HTTP transport without a domain allowlist.
325+
* `CKAN_ALLOWED_DOMAINS` (default-deny) is mandatory for HTTP; set
326+
* `CKAN_HTTP_ALLOW_ALL=true` to explicitly opt out (logs a warning).
327+
* stdio is unaffected — it stays open so any portal can be queried locally.
328+
*/
329+
export function assertHttpAllowlistConfigured(): void {
330+
const raw = typeof process !== 'undefined' ? (process.env.CKAN_ALLOWED_DOMAINS ?? '') : '';
331+
const domains = raw.split(',').map(s => s.trim()).filter(Boolean);
332+
if (domains.length > 0) return;
333+
334+
if (typeof process !== 'undefined' && process.env.CKAN_HTTP_ALLOW_ALL === 'true') {
335+
console.error(
336+
'[SECURITY WARNING] HTTP transport is running WITHOUT a domain allowlist ' +
337+
'(CKAN_HTTP_ALLOW_ALL=true). Any client can drive requests to arbitrary hosts. ' +
338+
'Set CKAN_ALLOWED_DOMAINS to restrict which CKAN hosts can be queried.'
339+
);
340+
return;
341+
}
342+
343+
throw new Error(
344+
'Refusing to start HTTP transport without a domain allowlist.\n' +
345+
'Set CKAN_ALLOWED_DOMAINS="portal1.org,portal2.gov" to restrict which hosts can be queried,\n' +
346+
'or set CKAN_HTTP_ALLOW_ALL=true to explicitly run without restriction (NOT recommended when network-exposed).'
347+
);
348+
}
349+
350+
type ResolvedAddress = { address: string; family?: number };
351+
type DnsLookupModule = {
352+
lookup: (
353+
hostname: string,
354+
options: { all: true; family?: number },
355+
callback: (err: NodeJS.ErrnoException | null, addresses: ResolvedAddress[]) => void
356+
) => void;
357+
};
358+
359+
/**
360+
* Build a Node `lookup` function (for http/https Agents) that resolves the hostname,
361+
* rejects if ANY resolved address is private/internal, and pins the connection to the
362+
* validated address — closing the DNS-name SSRF bypass and DNS-rebinding (the IP the
363+
* socket connects to is exactly the one we validated, no second resolution).
364+
* Exported with an injectable dns module so it can be unit-tested without real DNS.
365+
*/
366+
export function createSsrfSafeLookup(dnsModule: DnsLookupModule) {
367+
return function ssrfSafeLookup(hostname: string, options: any, callback: any): void {
368+
if (typeof options === 'function') {
369+
callback = options;
370+
options = {};
371+
}
372+
const family = options && typeof options === 'object' ? options.family : undefined;
373+
dnsModule.lookup(hostname, { all: true, family: family || 0 }, (err, addresses) => {
374+
if (err) {
375+
callback(err);
376+
return;
377+
}
378+
const list = Array.isArray(addresses) ? addresses : [addresses as ResolvedAddress];
379+
for (const a of list) {
380+
if (isBlockedIp(a.address)) {
381+
callback(new Error(
382+
`Access to private/internal IP addresses is not allowed ` +
383+
`("${hostname}" resolves to ${a.address}).`
384+
));
385+
return;
386+
}
387+
}
388+
if (options && options.all) {
389+
callback(null, list);
390+
return;
391+
}
392+
callback(null, list[0].address, list[0].family);
393+
});
394+
};
395+
}
396+
397+
let _safeAgents: Promise<{ httpAgent: unknown; httpsAgent: unknown } | null> | null = null;
398+
399+
/** Lazily build SSRF-safe http/https Agents (Node only). Returns null off Node/on failure. */
400+
function getSafeAgents(): Promise<{ httpAgent: unknown; httpsAgent: unknown } | null> {
401+
if (!_safeAgents) {
402+
_safeAgents = (async () => {
403+
try {
404+
// String-concatenated specifiers keep esbuild from bundling node builtins
405+
// into the Cloudflare Workers build (mirrors loadZlib above).
406+
const dnsMod = (await import("node:" + "dns")) as unknown as DnsLookupModule;
407+
const httpMod = (await import("node:" + "http")) as any;
408+
const httpsMod = (await import("node:" + "https")) as any;
409+
const lookup = createSsrfSafeLookup(dnsMod);
410+
return {
411+
httpAgent: new httpMod.Agent({ lookup }),
412+
httpsAgent: new httpsMod.Agent({ lookup }),
413+
};
414+
} catch {
415+
return null;
416+
}
417+
})();
418+
}
419+
return _safeAgents;
420+
}
421+
422+
type DnsResolver = (hostname: string) => Promise<ResolvedAddress[]>;
423+
let _dnsResolver: DnsResolver | null = null;
424+
425+
/** Test seam: override the DNS resolver used by `assertHostnameResolvesSafe`. */
426+
export function __setDnsResolverForTests(fn: DnsResolver | null): void {
427+
_dnsResolver = fn;
428+
}
429+
430+
/**
431+
* Resolve a hostname and throw if it maps to a private/internal IP.
432+
* Used by the fetch-based paths (e.g. sparql_query) that cannot attach a custom
433+
* lookup agent. No-op when DNS is unavailable (Cloudflare Workers — CF sandbox
434+
* already blocks internal addresses) or when resolution fails (request fails naturally).
435+
*/
436+
export async function assertHostnameResolvesSafe(hostname: string): Promise<void> {
437+
let addresses: ResolvedAddress[];
438+
try {
439+
if (_dnsResolver) {
440+
addresses = await _dnsResolver(hostname);
441+
} else {
442+
const dnsMod = (await import("node:" + "dns")) as any;
443+
addresses = await dnsMod.promises.lookup(hostname, { all: true });
444+
}
445+
} catch {
446+
return; // DNS unavailable (Workers) or resolution failed → let the request proceed/fail naturally
447+
}
448+
for (const a of addresses) {
449+
if (isBlockedIp(a.address)) {
450+
throw new Error(
451+
`Access to private/internal IP addresses is not allowed ` +
452+
`("${hostname}" resolves to ${a.address}).`
453+
);
454+
}
455+
}
456+
}
457+
310458
function auditLog(serverUrl: string, action: string, params: Record<string, any>, cacheHit: boolean): void {
311459
if (typeof process === 'undefined' || !(process as { versions?: { node?: string } }).versions?.node) return;
312460
const entry: Record<string, unknown> = {
@@ -384,10 +532,15 @@ export async function makeCkanRequest<T>(
384532
let decodedData: unknown;
385533

386534
if (isNode) {
535+
const safeAgents = await getSafeAgents();
387536
const response = await axios.get(url, {
388537
params,
389538
timeout: 30000,
390539
responseType: "arraybuffer",
540+
maxRedirects: 5,
541+
...(safeAgents
542+
? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent }
543+
: {}),
391544
headers: {
392545
Accept: 'application/json, text/plain, */*',
393546
'Accept-Language': 'en-US,en;q=0.9,it;q=0.8',

src/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export default {
220220
if (request.method === 'GET' && url.pathname === '/health') {
221221
return new Response(JSON.stringify({
222222
status: 'ok',
223-
version: '0.4.107',
223+
version: '0.4.108',
224224
tools: 20,
225225
resources: 7,
226226
prompts: 6,

tests/integration/sparql.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
22
import { formatSparqlMarkdown, formatSparqlJson, querySparqlEndpoint, validateSelectQuery, injectLimit } from '../../src/tools/sparql';
3+
import { __setDnsResolverForTests } from '../../src/utils/http';
34

45
describe('sparql_query', () => {
56
beforeEach(() => {
67
vi.stubGlobal('fetch', vi.fn());
78
vi.clearAllMocks();
9+
// Default: resolve every hostname to a public IP so tests stay offline.
10+
__setDnsResolverForTests(async () => [{ address: '93.184.216.34', family: 4 }]);
11+
});
12+
13+
afterAll(() => {
14+
__setDnsResolverForTests(null);
815
});
916

1017
const mockFetch = (payload: unknown, ok = true, status = 200) => {
@@ -67,6 +74,13 @@ describe('sparql_query', () => {
6774
).rejects.toThrow('private/internal');
6875
});
6976

77+
it('rejects hostnames that RESOLVE to an internal IP (DNS-name bypass)', async () => {
78+
__setDnsResolverForTests(async () => [{ address: '127.0.0.1', family: 4 }]);
79+
await expect(
80+
querySparqlEndpoint('https://internal.lvh.me/sparql', 'SELECT * WHERE { }')
81+
).rejects.toThrow('private/internal');
82+
});
83+
7084
it('throws on HTTP error response', async () => {
7185
mockFetch({ error: 'bad query' }, false, 400);
7286

0 commit comments

Comments
 (0)