@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 +9 -0
- package/dist/index.js +117 -28
- package/dist/worker.js +77 -77
- package/package.json +1 -1
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
|
-
|
|
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",
|
|
@@ -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.
|
|
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) => {
|