@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.
- package/LOG.md +20 -0
- package/dist/index.js +64 -17
- 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
|
|
755
|
+
throw new SsrfGuardError(`Invalid URL: ${serverUrl}`);
|
|
725
756
|
}
|
|
726
757
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
727
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1098
|
+
throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
|
|
1052
1099
|
} else if (axiosError.code === "ENOTFOUND") {
|
|
1053
|
-
throw new
|
|
1100
|
+
throw new CkanTransportError(`Server not found: ${serverUrl}`, serverUrl);
|
|
1054
1101
|
} else {
|
|
1055
|
-
throw new
|
|
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.
|
|
6092
|
+
version: "0.4.129"
|
|
6046
6093
|
});
|
|
6047
6094
|
}
|
|
6048
6095
|
function registerAll(server) {
|