@aborruso/ckan-mcp-server 0.4.117 → 0.4.119

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 +56 -0
  2. package/dist/index.js +66 -7
  3. package/package.json +4 -3
package/LOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-08-20
4
+
5
+ ### v0.4.119 - classify IPv6 by value, not by prefix string (GHSA-x32r-mh7g-q2rf)
6
+
7
+ `0xRomSec` reported that `isBlockedIp` misses the IPv6 ranges that embed an IPv4 address: NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`) and IPv4-compatible (`::/96`). Verified against the real function - all eight cases in the report return `false`. `64:ff9b::a9fe:a9fe` is 169.254.169.254 written in IPv6, and the function is the single decision point for both the literal guard and the DNS-resolution guard, so the gap applied to both halves.
8
+
9
+ Fixed structurally rather than by appending a fifth prefix: `parseIpv6` expands any spelling (compressed, expanded, upper-case, leading zeros, dotted-quad tail, zone id) into eight 16-bit words, and the classifier decides on values. Unparseable input fails closed. Added `2001::/32` (Teredo) and IPv4 `198.18.0.0/15` as hardening - both embed *public* addresses and open no path to RFC1918, so they sit outside the vector.
10
+
11
+ The DNS half needed no change: `createSsrfSafeLookup` already passes `all: true` with `family: family || 0` and iterates every result, so an AAAA record was always inspected - it was the classifier that waved it through. Checked before writing the fix, since a `family: 4` there would have made the patch half a fix.
12
+
13
+ Tests: the eight advisory cases, four non-canonical spellings of the same address, four unparseable literals, `198.18.0.0/15` boundaries, plus three guard-level regressions that exercise the actual bypass chain (literal guard, `assertHostnameResolvesSafe`, connection-time lookup) rather than the classifier alone. 495 passing. Both build targets checked - `http.ts` is shared with the Workers bundle.
14
+
15
+ ## 2026-08-20
16
+
17
+ ### CVE-2026-76811/76812/76813 issued; advisory table added to SECURITY.md
18
+
19
+ GitHub issued CVEs for the three advisories still pending from the July batch: `GHSA-38f8` (CVE-2026-76811, High), `GHSA-vmrr` (CVE-2026-76812, Critical) and `GHSA-q5gv` (CVE-2026-76813, High). All three were already published and patched (0.4.110 / 0.4.111), so no action was required on the advisories themselves. The 2026-08-15 narrowing round (`GHSA-3369`, `GHSA-v3j5`, `GHSA-c499`, `GHSA-vqff`) is still without an outcome.
20
+
21
+ `SECURITY.md` now carries a GHSA → CVE → fixed-in table for all thirteen published advisories, plus a supported-versions note. Until now the CVE mapping existed only scattered across LOG entries.
22
+
23
+ Propagation remains the real gap. `GET /advisories?affects=@aborruso/ckan-mcp-server` still returns only two records (CVE-2026-33060, CVE-2026-53509) out of thirteen published advisories, so Dependabot has never alerted downstream users about the other eleven — including the Critical SSRF-to-cloud-metadata one. CVE-2026-61612 has had its CVE since 2026-06-22 without propagating, which argues against a plain review backlog. Escalated to GitHub Support as ticket **#4682889** (org `ondata`, category Repositories / repository features), 2026-08-20. The portal's AI triage confirmed the reading before letting the ticket through: the documented review window after publication is 72 hours, nothing in the docs describes a further maintainer action to force propagation, and inspecting why some advisories were promoted and others not requires Support. Draft kept in `tmp/github-support-advisory-propagation.md`.
24
+
25
+ ## 2026-08-15
26
+
27
+ ### CVE requests: three rejected advisories narrowed and resubmitted
28
+
29
+ GitHub issued CVE-2026-73844 (`GHSA-6f9w`), CVE-2026-73845 (`GHSA-83x6`) and CVE-2026-73846 (`GHSA-78x9`), and rejected three others under CNA rule 4.2.11 — each advisory described more than one independently fixable vulnerability. The requests originate from `GHSA-p5c9`, where the reporters (Gal3m, mrostamipoor) asked for one CVE per finding.
30
+
31
+ Rather than split published advisories into new public ones, each was **narrowed** to a single defect, with the rest demoted to impact or deployment context:
32
+
33
+ - `GHSA-3369` — kept the second-order SSRF (destination derived from resource `url` metadata); dropped the `Promise.all` amplification claim, which a concurrency cap fixes independently. CWE-400 removed; vector `S:U/C:L/A:L` (4.7) → `S:C/C:L/A:N` (4.0, still Medium).
34
+ - `GHSA-v3j5` — kept the missing `Origin`/`Host` validation. The MCP spec (Basic/Transports) draws the line for us: origin validation is **MUST**, loopback binding and authentication are **SHOULD**. The latter two stay as deployment context. CWE-306 and CWE-1327 removed.
35
+ - `GHSA-c499` — argued dependency rather than narrowing: one missing neutralization pass at the render boundary produces both the prompt injection and the content spoofing, and no patch fixes one without the other. The remediation section was rewritten as a single control (it previously listed two, which read as two defects). CWE-79 removed.
36
+
37
+ Also requested the CVE for `GHSA-vqff`, which was in the reporters' list but had neither a CVE nor a rejection — the request had apparently never been submitted.
38
+
39
+ API notes: `PATCH /repos/{o}/{r}/security-advisories/{ghsa}` accepts `summary`, `description`, `cwe_ids`, `cvss_vector_string` (mutually exclusive with `severity`); `POST .../{ghsa}/cve` re-requests the CVE. There is **no** comments endpoint (404, absent from the OpenAPI description) — advisory comments were posted by replying to the GitHub notification email.
40
+
41
+ Worth noting for expectations: a CVE is necessary but not sufficient for Dependabot alerts. Only 2 of the repo's advisories are in the global GitHub Advisory Database; CVE-2026-61612 has had a CVE since 2026-06-22 and is still not propagated.
42
+
43
+ ## 2026-08-06
44
+
45
+ ### v0.4.118 — pin the fetch path to the validated IP
46
+
47
+ `GHSA-r8hw-3fch-r42w` reported the v0.4.108 SSRF fix as bypassable by DNS rebinding, with a PoC pointing `ckan_package_search` at `7f000001.7f000001.rbndr.us`. Reproduced: the PoC is blocked. Both halves of that label decode to 127.0.0.1, so the name never rebinds — it is a plain DNS-name-to-loopback, the case v0.4.108 already closed. Replaying it against `localtest.me` with a listener on 127.0.0.1:8054 is rejected by `createSsrfSafeLookup`, and a genuine rebinding fake-DNS (public IP first, loopback second) through the axios agent produces exactly one lookup with the connection pinned to the validated address.
48
+
49
+ The class was right on the wrong path, though. `safeFetch()` — used by `sparql_query` and the MQA quality tools — validated the hostname with `assertHostnameResolvesSafe()` and then let undici resolve it a second time, which is a real TOCTOU window. `getSafeDispatcher()` now builds an `undici.Agent({ connect: { lookup: createSsrfSafeLookup(dns) } })` and `safeFetch` passes it on every hop, so the socket connects to the address that was just checked. `assertHostnameResolvesSafe()` stays as defence in depth for runtimes without a dispatcher (Workers, where the CF sandbox blocks internal egress anyway).
50
+
51
+ `undici` becomes an explicit dependency: it was only present transitively through wrangler, i.e. absent in production installs. The import specifier is assembled at runtime (`["und","ici"].join("")`) because esbuild constant-folds `"undici" + ""` and would otherwise bundle all of undici into `dist/index.js` (207 KB → 969 KB) and break the browser-platform Workers build.
52
+
53
+ Same trap on the other side: because the specifier is dynamic, the DXT bundle cannot contain undici either, and a `.dxt` unpacked by Claude Desktop has no `node_modules` to resolve it from — the dispatcher would have been null and the pin silently inert. `pack:dxt` now copies `node_modules/undici` into `dxt-staging/server/node_modules/` (+1.7 MB uncompressed), verified by resolving it from a copy of the staging dir outside the repo.
54
+
55
+ `engines.node` moves to `>=18.17.0`, undici 6's own floor — the old `>=18.0.0` would have promised installs that cannot work.
56
+
57
+ Also fixed the `serverInfo` version, hardcoded at `0.4.108` in `src/server.ts` and `src/worker.ts` while the package was at 0.4.117.
58
+
3
59
  ## 2026-08-04
4
60
 
5
61
  ### v0.4.117 — escaping portal-controlled strings
package/dist/index.js CHANGED
@@ -660,14 +660,51 @@ async function decodePossiblyCompressed(data, headers) {
660
660
  return text;
661
661
  }
662
662
  }
663
+ function parseIpv6(input) {
664
+ const v = input.split("%")[0];
665
+ if (v.includes(":::") || (v.match(/::/g) || []).length > 1) return null;
666
+ const [head, tail, ...rest] = v.split("::");
667
+ if (rest.length > 0) return null;
668
+ const compressed = v.includes("::");
669
+ const words = [];
670
+ const pushGroups = (part) => {
671
+ if (part === "") return true;
672
+ for (const g of part.split(":")) {
673
+ if (g.includes(".")) {
674
+ const m = g.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
675
+ if (!m) return false;
676
+ const o = m.slice(1).map(Number);
677
+ if (o.some((n) => n > 255)) return false;
678
+ words.push(o[0] << 8 | o[1], o[2] << 8 | o[3]);
679
+ continue;
680
+ }
681
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return false;
682
+ words.push(parseInt(g, 16));
683
+ }
684
+ return true;
685
+ };
686
+ if (!pushGroups(head)) return null;
687
+ const headLen = words.length;
688
+ if (!pushGroups(compressed ? tail : "")) return null;
689
+ const tailLen = words.length - headLen;
690
+ if (!compressed) return words.length === 8 ? words : null;
691
+ const gap = 8 - headLen - tailLen;
692
+ if (gap < 1) return null;
693
+ return [...words.slice(0, headLen), ...Array(gap).fill(0), ...words.slice(headLen)];
694
+ }
663
695
  function isBlockedIp(ip) {
664
696
  const v = ip.toLowerCase().trim();
665
697
  if (v.includes(":")) {
666
- if (v === "::1" || v === "::") return true;
667
- if (v.startsWith("fc") || v.startsWith("fd")) return true;
668
- if (v.startsWith("fe80")) return true;
669
- if (v.startsWith("::ffff:")) return true;
670
- return false;
698
+ const w = parseIpv6(v);
699
+ if (!w) return true;
700
+ const zeros = (n) => w.slice(0, n).every((x) => x === 0);
701
+ return zeros(6) || // ::/96 unspecified, loopback, IPv4-compatible
702
+ zeros(5) && w[5] === 65535 || // ::ffff:0:0/96 IPv4-mapped
703
+ w[0] === 100 && w[1] === 65435 && (w[2] === 0 && w[3] === 0 && w[4] === 0 && w[5] === 0 || w[2] === 1) || // 64:ff9b::/96 + 64:ff9b:1::/48 NAT64
704
+ w[0] === 8194 || // 2002::/16 6to4
705
+ w[0] === 8193 && w[1] === 0 || // 2001::/32 Teredo
706
+ (w[0] & 65024) === 64512 || // fc00::/7 unique local
707
+ (w[0] & 65472) === 65152;
671
708
  }
672
709
  const m = v.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
673
710
  if (!m) return false;
@@ -680,6 +717,7 @@ function isBlockedIp(ip) {
680
717
  o1 === 169 && o2 === 254 || // 169.254.0.0/16 link-local / cloud metadata
681
718
  o1 === 172 && o2 >= 16 && o2 <= 31 || // 172.16.0.0/12 private
682
719
  o1 === 192 && o2 === 168 || // 192.168.0.0/16 private
720
+ o1 === 198 && (o2 === 18 || o2 === 19) || // 198.18.0.0/15 benchmarking
683
721
  o1 === 255;
684
722
  }
685
723
  function validateServerUrl(serverUrl) {
@@ -776,6 +814,22 @@ function getSafeAgents() {
776
814
  }
777
815
  return _safeAgents;
778
816
  }
817
+ var _safeDispatcher = null;
818
+ function getSafeDispatcher() {
819
+ if (!_safeDispatcher) {
820
+ _safeDispatcher = (async () => {
821
+ try {
822
+ const dnsMod = await import("node:dns");
823
+ const undiciSpecifier = ["und", "ici"].join("");
824
+ const undiciMod = await import(undiciSpecifier);
825
+ return new undiciMod.Agent({ connect: { lookup: createSsrfSafeLookup(dnsMod) } });
826
+ } catch {
827
+ return null;
828
+ }
829
+ })();
830
+ }
831
+ return _safeDispatcher;
832
+ }
779
833
  var _dnsResolver = null;
780
834
  async function assertHostnameResolvesSafe(hostname) {
781
835
  let lookup;
@@ -806,9 +860,14 @@ async function assertHostnameResolvesSafe(hostname) {
806
860
  }
807
861
  async function safeFetch(url, init = {}, opts = {}) {
808
862
  const maxHops = opts.maxHops ?? 3;
863
+ const dispatcher = await getSafeDispatcher();
809
864
  let current = url;
810
865
  for (let hop = 0; ; hop++) {
811
- const response = await fetch(current, { ...init, redirect: "manual" });
866
+ const response = await fetch(current, {
867
+ ...init,
868
+ redirect: "manual",
869
+ ...dispatcher ? { dispatcher } : {}
870
+ });
812
871
  const status = response.status;
813
872
  const location = response.headers.get("location");
814
873
  const isRedirect = (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) && !!location;
@@ -5736,7 +5795,7 @@ var registerAllPrompts = (server) => {
5736
5795
  function createServer() {
5737
5796
  return new McpServer({
5738
5797
  name: "ckan-mcp-server",
5739
- version: "0.4.108"
5798
+ version: "0.4.118"
5740
5799
  });
5741
5800
  }
5742
5801
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.117",
3
+ "version": "0.4.119",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "test:watch": "vitest --watch",
26
26
  "test:coverage": "vitest --coverage",
27
27
  "build:dxt": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dxt-staging/server/index.js",
28
- "pack:dxt": "rm -rf dxt-staging && mkdir -p dxt-staging/server && npm run build:dxt && echo '{\"type\":\"commonjs\"}' > dxt-staging/server/package.json && cp manifest.json icon.png dxt-staging/ && dxt pack dxt-staging ckan-mcp-server.dxt && rm -rf dxt-staging",
28
+ "pack:dxt": "rm -rf dxt-staging && mkdir -p dxt-staging/server && npm run build:dxt && echo '{\"type\":\"commonjs\"}' > dxt-staging/server/package.json && mkdir -p dxt-staging/server/node_modules && cp -r node_modules/undici dxt-staging/server/node_modules/ && cp manifest.json icon.png dxt-staging/ && dxt pack dxt-staging ckan-mcp-server.dxt && rm -rf dxt-staging",
29
29
  "pack:skill": "mkdir -p tmp && cd skills && zip -r ../tmp/ckan-mcp.skill ckan-mcp/ && cd ..",
30
30
  "prepack": "cp README.md .readme-full.md && cp .readme-npm.md README.md",
31
31
  "postpack": "mv .readme-full.md README.md"
@@ -44,6 +44,7 @@
44
44
  "@modelcontextprotocol/sdk": "^1.27.1",
45
45
  "axios": "^1.7.2",
46
46
  "express": "^4.19.2",
47
+ "undici": "^6.28.0",
47
48
  "zod": "^3.23.8"
48
49
  },
49
50
  "devDependencies": {
@@ -70,6 +71,6 @@
70
71
  "AGENTS.md"
71
72
  ],
72
73
  "engines": {
73
- "node": ">=18.0.0"
74
+ "node": ">=18.17.0"
74
75
  }
75
76
  }