@aborruso/ckan-mcp-server 0.4.107 → 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,14 @@
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
+
3
12
  ## 2026-06-18
4
13
 
5
14
  ### v0.4.107
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",
@@ -4639,6 +4726,7 @@ async function querySparqlEndpoint(endpointUrl, query) {
4639
4726
  if (url.protocol !== "https:") {
4640
4727
  throw new Error("Only HTTPS endpoints are allowed");
4641
4728
  }
4729
+ await assertHostnameResolvesSafe(url.hostname);
4642
4730
  const sparqlConfig = getSparqlConfig(endpointUrl);
4643
4731
  const method = sparqlConfig?.method ?? "POST";
4644
4732
  const commonHeaders = {
@@ -5536,7 +5624,7 @@ var registerAllPrompts = (server) => {
5536
5624
  function createServer() {
5537
5625
  return new McpServer({
5538
5626
  name: "ckan-mcp-server",
5539
- version: "0.4.107"
5627
+ version: "0.4.108"
5540
5628
  });
5541
5629
  }
5542
5630
  function registerAll(server) {
@@ -5567,6 +5655,7 @@ async function runStdio(server) {
5567
5655
  import express from "express";
5568
5656
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5569
5657
  async function runHTTP() {
5658
+ assertHttpAllowlistConfigured();
5570
5659
  const app = express();
5571
5660
  app.use(express.json());
5572
5661
  app.get("/.well-known/oauth-authorization-server", (_req, res) => {