@aborruso/ckan-mcp-server 0.4.106 → 0.4.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # LOG
2
2
 
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
+
12
+ ## 2026-06-18
13
+
14
+ ### v0.4.107
15
+
16
+ - `ckan_package_show`: surface DCAT-AP fields already returned by package_show but previously hidden in markdown output — Rights Holder (`holder_name` via readDcatExtra), Publisher (`publisher_name`), Update Frequency (`frequency`), Language (`language`), Access Rights (`access_rights`). Each printed only when present; no new tools or API calls. `conforms_to` deliberately excluded (renders as raw JSON). Verified on dati.gov.it (DCAT-AP-IT) and open.canada.ca (no regression).
17
+
3
18
  ## 2026-05-31
4
19
 
5
20
  ### v0.4.106
package/README.md CHANGED
@@ -13,6 +13,8 @@ This MCP server removes that barrier. Once connected, your AI assistant can sear
13
13
 
14
14
  **This is possible because of open standards and open source.** CKAN exposes a public API. Metadata follows [DCAT](https://www.w3.org/TR/vocab-dcat/), an open W3C standard. Both are free to use and maintained by open communities. This server stands on that foundation.
15
15
 
16
+ > **Adopted by AgID** — This project has been [reused by AgID](https://github.com/agID/ckan-mcp-server), Italy's Agency for Digital Italy, as part of its effort to make public open data more accessible, immediate, and easier to consult through AI.
17
+
16
18
  ---
17
19
 
18
20
  ## Quick Start
package/dist/index.js CHANGED
@@ -645,6 +645,28 @@ async function decodePossiblyCompressed(data, headers) {
645
645
  return text;
646
646
  }
647
647
  }
648
+ function isBlockedIp(ip) {
649
+ const v = ip.toLowerCase().trim();
650
+ if (v.includes(":")) {
651
+ if (v === "::1" || v === "::") return true;
652
+ if (v.startsWith("fc") || v.startsWith("fd")) return true;
653
+ if (v.startsWith("fe80")) return true;
654
+ if (v.startsWith("::ffff:")) return true;
655
+ return false;
656
+ }
657
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
658
+ if (!m) return false;
659
+ const o1 = Number(m[1]);
660
+ const o2 = Number(m[2]);
661
+ return o1 === 0 || // 0.0.0.0/8
662
+ o1 === 10 || // 10.0.0.0/8 private
663
+ o1 === 127 || // 127.0.0.0/8 loopback
664
+ o1 === 100 && o2 >= 64 && o2 <= 127 || // 100.64.0.0/10 shared
665
+ o1 === 169 && o2 === 254 || // 169.254.0.0/16 link-local / cloud metadata
666
+ o1 === 172 && o2 >= 16 && o2 <= 31 || // 172.16.0.0/12 private
667
+ o1 === 192 && o2 === 168 || // 192.168.0.0/16 private
668
+ o1 === 255;
669
+ }
648
670
  function validateServerUrl(serverUrl) {
649
671
  let parsed;
650
672
  try {
@@ -664,33 +686,11 @@ function validateServerUrl(serverUrl) {
664
686
  if (BLOCKED_HOSTNAMES.has(hostname)) {
665
687
  throw new Error(`Access to "${hostname}" is not allowed.`);
666
688
  }
667
- const ipv4 = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
668
- if (ipv4) {
669
- const [o1, o2] = ipv4.slice(1).map(Number);
670
- const blocked = o1 === 0 || // 0.0.0.0/8
671
- o1 === 10 || // 10.0.0.0/8 private
672
- o1 === 127 || // 127.0.0.0/8 loopback
673
- o1 === 100 && o2 >= 64 && o2 <= 127 || // 100.64.0.0/10 shared
674
- o1 === 169 && o2 === 254 || // 169.254.0.0/16 link-local / AWS metadata
675
- o1 === 172 && o2 >= 16 && o2 <= 31 || // 172.16.0.0/12 private
676
- o1 === 192 && o2 === 168 || // 192.168.0.0/16 private
677
- o1 === 255;
678
- if (blocked) {
679
- throw new Error(`Access to private/internal IP addresses is not allowed.`);
680
- }
681
- }
682
- if (hostname.startsWith("[")) {
683
- const ipv6 = hostname.slice(1, -1);
684
- const lower = ipv6.toLowerCase();
685
- const blockedIpv6 = lower === "::1" || // loopback
686
- lower === "::" || // unspecified
687
- lower.startsWith("fc") || // fc00::/7 unique local
688
- lower.startsWith("fd") || // fd00::/8 unique local
689
- lower.startsWith("fe80") || // fe80::/10 link-local
690
- lower.startsWith("::ffff:");
691
- if (blockedIpv6) {
692
- throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
693
- }
689
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) && isBlockedIp(hostname)) {
690
+ throw new Error(`Access to private/internal IP addresses is not allowed.`);
691
+ }
692
+ if (hostname.startsWith("[") && isBlockedIp(hostname.slice(1, -1))) {
693
+ throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
694
694
  }
695
695
  const rawAllowed = typeof process !== "undefined" ? process.env.CKAN_ALLOWED_DOMAINS ?? "" : "";
696
696
  const allowedDomains = rawAllowed.split(",").map((s) => s.trim()).filter(Boolean);
@@ -698,6 +698,90 @@ function validateServerUrl(serverUrl) {
698
698
  throw new Error(`Domain "${hostname}" is not in the allowed list (CKAN_ALLOWED_DOMAINS).`);
699
699
  }
700
700
  }
701
+ function assertHttpAllowlistConfigured() {
702
+ const raw = typeof process !== "undefined" ? process.env.CKAN_ALLOWED_DOMAINS ?? "" : "";
703
+ const domains = raw.split(",").map((s) => s.trim()).filter(Boolean);
704
+ if (domains.length > 0) return;
705
+ if (typeof process !== "undefined" && process.env.CKAN_HTTP_ALLOW_ALL === "true") {
706
+ console.error(
707
+ "[SECURITY WARNING] HTTP transport is running WITHOUT a domain allowlist (CKAN_HTTP_ALLOW_ALL=true). Any client can drive requests to arbitrary hosts. Set CKAN_ALLOWED_DOMAINS to restrict which CKAN hosts can be queried."
708
+ );
709
+ return;
710
+ }
711
+ throw new Error(
712
+ 'Refusing to start HTTP transport without a domain allowlist.\nSet CKAN_ALLOWED_DOMAINS="portal1.org,portal2.gov" to restrict which hosts can be queried,\nor set CKAN_HTTP_ALLOW_ALL=true to explicitly run without restriction (NOT recommended when network-exposed).'
713
+ );
714
+ }
715
+ function createSsrfSafeLookup(dnsModule) {
716
+ return function ssrfSafeLookup(hostname, options, callback) {
717
+ if (typeof options === "function") {
718
+ callback = options;
719
+ options = {};
720
+ }
721
+ const family = options && typeof options === "object" ? options.family : void 0;
722
+ dnsModule.lookup(hostname, { all: true, family: family || 0 }, (err, addresses) => {
723
+ if (err) {
724
+ callback(err);
725
+ return;
726
+ }
727
+ const list = Array.isArray(addresses) ? addresses : [addresses];
728
+ for (const a of list) {
729
+ if (isBlockedIp(a.address)) {
730
+ callback(new Error(
731
+ `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
732
+ ));
733
+ return;
734
+ }
735
+ }
736
+ if (options && options.all) {
737
+ callback(null, list);
738
+ return;
739
+ }
740
+ callback(null, list[0].address, list[0].family);
741
+ });
742
+ };
743
+ }
744
+ var _safeAgents = null;
745
+ function getSafeAgents() {
746
+ if (!_safeAgents) {
747
+ _safeAgents = (async () => {
748
+ try {
749
+ const dnsMod = await import("node:dns");
750
+ const httpMod = await import("node:http");
751
+ const httpsMod = await import("node:https");
752
+ const lookup = createSsrfSafeLookup(dnsMod);
753
+ return {
754
+ httpAgent: new httpMod.Agent({ lookup }),
755
+ httpsAgent: new httpsMod.Agent({ lookup })
756
+ };
757
+ } catch {
758
+ return null;
759
+ }
760
+ })();
761
+ }
762
+ return _safeAgents;
763
+ }
764
+ var _dnsResolver = null;
765
+ async function assertHostnameResolvesSafe(hostname) {
766
+ let addresses;
767
+ try {
768
+ if (_dnsResolver) {
769
+ addresses = await _dnsResolver(hostname);
770
+ } else {
771
+ const dnsMod = await import("node:dns");
772
+ addresses = await dnsMod.promises.lookup(hostname, { all: true });
773
+ }
774
+ } catch {
775
+ return;
776
+ }
777
+ for (const a of addresses) {
778
+ if (isBlockedIp(a.address)) {
779
+ throw new Error(
780
+ `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
781
+ );
782
+ }
783
+ }
784
+ }
701
785
  function auditLog(serverUrl, action, params, cacheHit) {
702
786
  if (typeof process === "undefined" || !process.versions?.node) return;
703
787
  const entry = {
@@ -755,10 +839,13 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
755
839
  try {
756
840
  let decodedData;
757
841
  if (isNode) {
842
+ const safeAgents = await getSafeAgents();
758
843
  const response = await axios.get(url, {
759
844
  params,
760
845
  timeout: 3e4,
761
846
  responseType: "arraybuffer",
847
+ maxRedirects: 5,
848
+ ...safeAgents ? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent } : {},
762
849
  headers: {
763
850
  Accept: "application/json, text/plain, */*",
764
851
  "Accept-Language": "en-US,en;q=0.9,it;q=0.8",
@@ -1230,7 +1317,24 @@ var formatPackageShowMarkdown = (result, serverUrl) => {
1230
1317
  if (result.modified) markdown += `- **Modified (Content)**: ${formatDate(result.modified)}
1231
1318
  `;
1232
1319
  markdown += `- **Metadata Modified (Record)**: ${formatDate(result.metadata_modified)}
1233
-
1320
+ `;
1321
+ const holderName = readDcatExtra(result, "holder_name");
1322
+ if (holderName) markdown += `- **Rights Holder (dct:rightsHolder)**: ${holderName}
1323
+ `;
1324
+ const publisherName = readDcatExtra(result, "publisher_name");
1325
+ if (publisherName) markdown += `- **Publisher (dct:publisher)**: ${publisherName}
1326
+ `;
1327
+ const dcatField = (key) => typeof result[key] === "string" ? result[key] : "";
1328
+ const frequency = dcatField("frequency");
1329
+ if (frequency) markdown += `- **Update Frequency (dct:accrualPeriodicity)**: ${frequency}
1330
+ `;
1331
+ const language = dcatField("language");
1332
+ if (language) markdown += `- **Language (dct:language)**: ${language}
1333
+ `;
1334
+ const accessRights = dcatField("access_rights");
1335
+ if (accessRights) markdown += `- **Access Rights (dct:accessRights)**: ${accessRights}
1336
+ `;
1337
+ markdown += `
1234
1338
  `;
1235
1339
  if (result.organization) {
1236
1340
  markdown += `## Organization
@@ -4622,6 +4726,7 @@ async function querySparqlEndpoint(endpointUrl, query) {
4622
4726
  if (url.protocol !== "https:") {
4623
4727
  throw new Error("Only HTTPS endpoints are allowed");
4624
4728
  }
4729
+ await assertHostnameResolvesSafe(url.hostname);
4625
4730
  const sparqlConfig = getSparqlConfig(endpointUrl);
4626
4731
  const method = sparqlConfig?.method ?? "POST";
4627
4732
  const commonHeaders = {
@@ -5519,7 +5624,7 @@ var registerAllPrompts = (server) => {
5519
5624
  function createServer() {
5520
5625
  return new McpServer({
5521
5626
  name: "ckan-mcp-server",
5522
- version: "0.4.106"
5627
+ version: "0.4.108"
5523
5628
  });
5524
5629
  }
5525
5630
  function registerAll(server) {
@@ -5550,6 +5655,7 @@ async function runStdio(server) {
5550
5655
  import express from "express";
5551
5656
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5552
5657
  async function runHTTP() {
5658
+ assertHttpAllowlistConfigured();
5553
5659
  const app = express();
5554
5660
  app.use(express.json());
5555
5661
  app.get("/.well-known/oauth-authorization-server", (_req, res) => {