@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 +15 -0
- package/README.md +2 -0
- package/dist/index.js +135 -29
- package/dist/worker.js +98 -93
- package/package.json +1 -1
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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.
|
|
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) => {
|