@aborruso/ckan-mcp-server 0.4.127 → 0.4.129

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.
Files changed (3) hide show
  1. package/LOG.md +20 -0
  2. package/dist/index.js +64 -17
  3. package/package.json +1 -1
package/LOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-10-06
4
+
5
+ ### v0.4.129
6
+
7
+ Ships #558 (by @paoValle, closes #31): error messages now tell the caller what to try next where they said nothing. A timeout, DNS failure or network error gets "Portal unreachable. Retry later, or call `ckan_status_show`" (noting its answer can be cached up to an hour), on both Node and Workers; 5xx errors point at `ckan_status_show` too; a 404 on `ckan_group_show` suggests `ckan_group_list`/`ckan_group_search`. A request refused by the SSRF guard keeps the guard's own message instead of a retry hint. Released the same day as the merge, by explicit exception to the release cadence rule. Tests 587 passed, smoke 17/17.
8
+
9
+ ## 2026-09-28
10
+
11
+ ### Advisory GHSA-279h-fmcr-4rwv published
12
+
13
+ Reported by @manus-pi, accepted and published as Medium (CVSS 5.8): affected `<= 0.4.127`, patched `0.4.128`. CVE requested, not yet assigned. v0.4.128 is live on npm (with provenance), the MCP Registry and the Worker. `SECURITY.md` table updated; GHSA-x32r-mh7g-q2rf got CVE-2026-92146 in the meantime.
14
+
15
+ ### v0.4.128
16
+
17
+ Ships #556: every redirect hop in `makeCkanRequest` is re-validated (GHSA-279h-fmcr-4rwv), on both the Node/axios and Workers/fetch paths. Smoke 17/17.
18
+
19
+ ### Security: validate every redirect hop in `makeCkanRequest` (GHSA-279h-fmcr-4rwv)
20
+
21
+ `makeCkanRequest` followed up to 5 redirects via axios without re-validating the target. The SSRF-safe agent `lookup` is skipped by Node for IP literals, so a public URL 302-ing to `http://127.0.0.1/` or `169.254.169.254` was followed, and an internal endpoint answering `success: true` was returned in full (reproduced end-to-end via httpbin `redirect-to`). Fix: axios `beforeRedirect` calls `validateServerUrl` on each hop; the Workers `fetch` branch now uses `safeFetch` (`maxHops: 5`). Side effect: `CKAN_ALLOWED_DOMAINS` now applies to redirect targets too, so a portal redirecting to another host needs both listed. The public Worker (no allowlist) is unaffected for public portals.
22
+
3
23
  ## 2026-09-24
4
24
 
5
25
  ### v0.4.127
package/dist/index.js CHANGED
@@ -476,7 +476,36 @@ var CkanApiError = class extends Error {
476
476
  this.serverUrl = serverUrl;
477
477
  }
478
478
  };
479
+ var CkanTransportError = class extends Error {
480
+ /** The portal the request went to, as the caller wrote it. */
481
+ serverUrl;
482
+ constructor(message, serverUrl) {
483
+ super(message);
484
+ this.name = "CkanTransportError";
485
+ this.serverUrl = serverUrl;
486
+ }
487
+ };
488
+ var SsrfGuardError = class extends Error {
489
+ constructor(message) {
490
+ super(message);
491
+ this.name = "SsrfGuardError";
492
+ }
493
+ };
494
+ function unwrapSsrfGuardError(axiosError) {
495
+ if (axiosError.cause instanceof SsrfGuardError) return axiosError.cause;
496
+ const redirected = axiosError.cause?.cause;
497
+ return redirected instanceof SsrfGuardError ? redirected : null;
498
+ }
479
499
  function formatCkanError(error, _toolName) {
500
+ if (error instanceof CkanTransportError) {
501
+ const migration2 = error.serverUrl ? getPortalMigration(error.serverUrl) : null;
502
+ if (migration2) {
503
+ return `${error.message}
504
+ \u2192 ${migration2.notice} See ${migration2.docs_url}`;
505
+ }
506
+ return `${error.message}
507
+ \u2192 Portal unreachable. Retry later, or call \`ckan_status_show\` to check portal health (its answer can come from the cache for up to an hour).`;
508
+ }
480
509
  if (!(error instanceof CkanApiError)) {
481
510
  return error instanceof Error ? error.message : String(error);
482
511
  }
@@ -496,6 +525,8 @@ function formatCkanError(error, _toolName) {
496
525
  hint = "\u2192 Use `ckan_package_search` to find a valid dataset name or ID.";
497
526
  } else if (action === "organization_show") {
498
527
  hint = "\u2192 Use `ckan_organization_list` or `ckan_organization_search` to discover valid organization names.";
528
+ } else if (action === "group_show") {
529
+ hint = "\u2192 Use `ckan_group_list` or `ckan_group_search` to discover valid group names.";
499
530
  }
500
531
  } else if (status === 400) {
501
532
  if (action === "datastore_search_sql") {
@@ -506,9 +537,9 @@ function formatCkanError(error, _toolName) {
506
537
  } else if (status === 409 || status === 422) {
507
538
  hint = "\u2192 Portal rejected the request \u2014 parameters may conflict; simplify filters and retry.";
508
539
  } else if (status === 503 || status === 502 || status === 504) {
509
- hint = "\u2192 Portal temporarily unavailable \u2014 retry in a few seconds.";
540
+ hint = "\u2192 Portal temporarily unavailable \u2014 retry in a few seconds, or call `ckan_status_show` to check portal health (cached up to an hour).";
510
541
  } else if (status === 500) {
511
- hint = "\u2192 Portal internal error \u2014 try a different portal or retry later.";
542
+ hint = "\u2192 Portal internal error \u2014 try a different portal or retry later, and call `ckan_status_show` to check portal health (cached up to an hour).";
512
543
  } else if (status === void 0) {
513
544
  hint = "\u2192 The portal may not support this action, or the endpoint is unavailable.";
514
545
  }
@@ -721,10 +752,10 @@ function validateServerUrl(serverUrl) {
721
752
  try {
722
753
  parsed = new URL(serverUrl);
723
754
  } catch {
724
- throw new Error(`Invalid URL: ${serverUrl}`);
755
+ throw new SsrfGuardError(`Invalid URL: ${serverUrl}`);
725
756
  }
726
757
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
727
- throw new Error(`Disallowed protocol "${parsed.protocol}". Only http and https are allowed.`);
758
+ throw new SsrfGuardError(`Disallowed protocol "${parsed.protocol}". Only http and https are allowed.`);
728
759
  }
729
760
  const hostname = parsed.hostname.toLowerCase();
730
761
  const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
@@ -733,18 +764,18 @@ function validateServerUrl(serverUrl) {
733
764
  "ip6-loopback"
734
765
  ]);
735
766
  if (BLOCKED_HOSTNAMES.has(hostname)) {
736
- throw new Error(`Access to "${hostname}" is not allowed.`);
767
+ throw new SsrfGuardError(`Access to "${hostname}" is not allowed.`);
737
768
  }
738
769
  if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) && isBlockedIp(hostname)) {
739
- throw new Error(`Access to private/internal IP addresses is not allowed.`);
770
+ throw new SsrfGuardError(`Access to private/internal IP addresses is not allowed.`);
740
771
  }
741
772
  if (hostname.startsWith("[") && isBlockedIp(hostname.slice(1, -1))) {
742
- throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
773
+ throw new SsrfGuardError(`Access to private/internal IPv6 addresses is not allowed.`);
743
774
  }
744
775
  const rawAllowed = typeof process !== "undefined" ? process.env.CKAN_ALLOWED_DOMAINS ?? "" : "";
745
776
  const allowedDomains = rawAllowed.split(",").map((s) => s.trim()).filter(Boolean);
746
777
  if (allowedDomains.length > 0 && !allowedDomains.includes(hostname)) {
747
- throw new Error(`Domain "${hostname}" is not in the allowed list (CKAN_ALLOWED_DOMAINS).`);
778
+ throw new SsrfGuardError(`Domain "${hostname}" is not in the allowed list (CKAN_ALLOWED_DOMAINS).`);
748
779
  }
749
780
  }
750
781
  function assertHttpAllowlistConfigured() {
@@ -776,7 +807,7 @@ function createSsrfSafeLookup(dnsModule) {
776
807
  const list = Array.isArray(addresses) ? addresses : [addresses];
777
808
  for (const a of list) {
778
809
  if (isBlockedIp(a.address)) {
779
- callback(new Error(
810
+ callback(new SsrfGuardError(
780
811
  `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
781
812
  ));
782
813
  return;
@@ -844,11 +875,11 @@ async function assertHostnameResolvesSafe(hostname) {
844
875
  try {
845
876
  addresses = await lookup(hostname);
846
877
  } catch {
847
- throw new Error(`Cannot resolve "${hostname}" for SSRF validation (failing closed).`);
878
+ throw new SsrfGuardError(`Cannot resolve "${hostname}" for SSRF validation (failing closed).`);
848
879
  }
849
880
  for (const a of addresses) {
850
881
  if (isBlockedIp(a.address)) {
851
- throw new Error(
882
+ throw new SsrfGuardError(
852
883
  `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
853
884
  );
854
885
  }
@@ -945,6 +976,12 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
945
976
  timeout: 3e4,
946
977
  responseType: "arraybuffer",
947
978
  maxRedirects: 5,
979
+ // Re-validate every redirect hop: Node skips the agent's `lookup` for IP
980
+ // literals, so a public host 302-ing to e.g. 169.254.169.254 would otherwise
981
+ // be followed. Also enforces CKAN_ALLOWED_DOMAINS on redirect targets.
982
+ beforeRedirect: (redirectOptions) => {
983
+ validateServerUrl(redirectOptions.href);
984
+ },
948
985
  // Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
949
986
  // target itself, bypassing the SSRF-safe lookup pinned in the agents below.
950
987
  proxy: false,
@@ -979,7 +1016,7 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
979
1016
  const timeoutId = setTimeout(() => controller.abort(), 3e4);
980
1017
  let response;
981
1018
  try {
982
- response = await fetch(fetchUrl, {
1019
+ response = await safeFetch(fetchUrl, {
983
1020
  method: "GET",
984
1021
  signal: controller.signal,
985
1022
  headers: {
@@ -987,7 +1024,15 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
987
1024
  "Accept-Encoding": "identity",
988
1025
  "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
989
1026
  }
990
- });
1027
+ }, { maxHops: 5 });
1028
+ } catch (error) {
1029
+ if (controller.signal.aborted) {
1030
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1031
+ }
1032
+ if (error instanceof TypeError) {
1033
+ throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1034
+ }
1035
+ throw error;
991
1036
  } finally {
992
1037
  clearTimeout(timeoutId);
993
1038
  }
@@ -1042,17 +1087,19 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
1042
1087
  if (error instanceof CkanApiError) throw error;
1043
1088
  if (axios.isAxiosError(error)) {
1044
1089
  const axiosError = error;
1090
+ const guardError = unwrapSsrfGuardError(axiosError);
1091
+ if (guardError) throw guardError;
1045
1092
  if (axiosError.response) {
1046
1093
  const status = axiosError.response.status;
1047
1094
  const data = axiosError.response.data;
1048
1095
  const errorMsg = data?.error?.message || data?.error || "Unknown error";
1049
1096
  throw new CkanApiError(`CKAN API error (${status}): ${errorMsg}`, status, action, serverUrl);
1050
1097
  } else if (axiosError.code === "ECONNABORTED") {
1051
- throw new Error(`Request timeout connecting to ${serverUrl}`);
1098
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1052
1099
  } else if (axiosError.code === "ENOTFOUND") {
1053
- throw new Error(`Server not found: ${serverUrl}`);
1100
+ throw new CkanTransportError(`Server not found: ${serverUrl}`, serverUrl);
1054
1101
  } else {
1055
- throw new Error(`Network error: ${axiosError.message}`);
1102
+ throw new CkanTransportError(`Network error: ${axiosError.message}`, serverUrl);
1056
1103
  }
1057
1104
  }
1058
1105
  throw error;
@@ -6042,7 +6089,7 @@ var registerAllPrompts = (server) => {
6042
6089
  function createServer() {
6043
6090
  return new McpServer({
6044
6091
  name: "ckan-mcp-server",
6045
- version: "0.4.127"
6092
+ version: "0.4.129"
6046
6093
  });
6047
6094
  }
6048
6095
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.127",
3
+ "version": "0.4.129",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {