@aborruso/ckan-mcp-server 0.4.112 → 0.4.114

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 (4) hide show
  1. package/LOG.md +54 -25
  2. package/dist/index.js +107 -107
  3. package/dist/worker.js +112 -115
  4. package/package.json +1 -1
package/LOG.md CHANGED
@@ -1,47 +1,76 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-07-31
4
+
5
+ ### v0.4.114
6
+
7
+ structuredContent capped like the text (closes #39).
8
+
9
+ The v0.4.113 cap applied to `content[].text` only, so any client reading the structured channel got the full payload — `ckan_tag_list limit=1000` on dati.gov.it: ~50K of text against 65,382 uncapped characters. The limit was a fiction for those clients.
10
+
11
+ - New `jsonToolResult()`: truncates once via `truncateJson` and parses the result back into `structuredContent`, so the two channels cannot disagree and `_truncated`/`_original_count` reach structured readers too. Applied to 13 call sites.
12
+ - `ckan_status_show` and `ckan_find_portals` pair structured output with Markdown text, so there is no truncated JSON to derive it from: they cap it on its own via the new `cappedStructured()`. My first pass left them uncapped calling them "bounded by shape" — wrong, as review pointed out: `status_show` is echoed straight from the portal, and the 50-result limit on `find_portals` bounds the number of entries, not the length of their titles and URLs. Only the `all_fields=false` count branches of `ckan_organization_list`/`ckan_group_list` stay outside the cap: a single integer.
13
+ - **Correction**: the deferral in v0.4.113 claimed capping would drop rows from `datastore-table-ui`. That was wrong and never verified — the UI resource is commented out in `src/resources/index.ts:18` and never registered, and `ckan_datastore_search` returns no `structuredContent` at all. No exception was needed. Same failure mode as the bug being fixed: a plausible claim about the code that nobody checked.
14
+ - 4 new tests (458 total). Docs realigned: `README.md`, `docs/JSON-OUTPUT.md`, `docs/DECISIONS.md`, `CLAUDE.md` all asserted the uncapped behaviour.
15
+
16
+ ### v0.4.113
17
+
18
+ JSON output always parseable (issue #39).
19
+
20
+ The docs promised "always valid JSON"; the code did not deliver it. Surfaced while reviewing PR #38 (an AI contribution by `averyquinnhq`), which documented the real behaviour and contradicted issue #37 — the PR was right.
21
+
22
+ - **Live repro**: `ckan_tag_list limit=1000` on dati.gov.it → 50,046 characters, `JSON.parse` fails. Not a corner case: an ordinary call.
23
+ - **`truncateJson`**: the last-resort branch cut the serialized string (`truncateText(JSON.stringify(...))`, with an in-code comment admitting "may produce invalid JSON"). It now empties arrays progressively and, when that is not enough, degrades to a valid `{_truncated, _error}`. `SHRINKABLE_KEYS` extended (+`rows`, `datasets`, `portals`, `facets`, `fields`) with a sacrifice order: bulk rows first, `fields` last.
24
+ - **8 tools + 4 Resources** used `truncateText(JSON.stringify(...))` directly: routed through `truncateJson`. `sparql_query` appended `/* output truncated */` to cut JSON (JSON has no comments): rewritten.
25
+ - **4 tools with no cap at all**: `ckan_get_mqa_quality`, `ckan_get_mqa_quality_details` (bare JSON.stringify), `ckan_find_portals`, `ckan_status_show` (markdown).
26
+ - **A third unparseable path**: no `catch` honoured `response_format` — every error came back as prose even with `json`. New `formatError()`; errors now return `{error, _error: true}` plus `isError: true`. Zod validation errors stay textual (emitted by the SDK before the handler runs).
27
+ - **`structuredContent` stays uncapped** (65,382 characters against 50,000 of text on tag_list): capping it would drop rows from `datastore-table-ui`, which consumes it. Decision deferred, documented in `docs/DECISIONS.md`.
28
+ - 10 new tests (`truncateJson` had none), 453 passing. E2e: `tag_list` from PARSE-FAIL to parse-ok; real errors parseable in json mode.
29
+ - `CONTRIBUTING.md`: section on AI-assisted contributions (disclosure, small diffs, verifiable claims).
30
+ - Review follow-up: `addDemoFooter()` was appended to JSON output in `quality.ts`, breaking parsing on Workers — now wraps the Markdown branch only, inside `truncateText`. Added `isError: true` to the two non-dati.gov.it guards. The `truncateJson` fallback now degrades further so it always respects small limits. 454 passing.
31
+
3
32
  ## 2026-07-09
4
33
 
5
34
  ### v0.4.112
6
35
 
7
- Security — Giro 3: hardening (chiude l'ultimo gruppo di advisory in triage).
36
+ Security — Round 3: hardening (closes the last group of advisories in triage).
8
37
 
9
- - **Error reflection**: `makeCkanRequest` non incorpora più il body upstream (`JSON.stringify(decodedData)`) nell'errore verso il caller — ora messaggio generico action-scoped, dettaglio solo su stderr (troncato). `worker.ts` catch-all: rimosso `error.message` dal campo `data` JSON-RPC (log solo server-side). Chiude il canale di lettura semi-cieco della SSRF.
10
- - **postMessage UI** (`resources/datastore-table-ui.ts`): l'origin dell'host viene pinnato dalla risposta all'handshake `ui/initialize`; i messaggi con dati vengono accettati solo da quell'origin e gli outbound usano il target origin esplicito (mai `'*'`).
11
- - **Prompt-injection su org/group**: esteso il contenimento c499 ai renderer di `organization.ts` e `group.ts` — `description` in blocco untrusted (`wrapUntrusted`), newline collassati nelle liste.
12
- - 3 nuovi test (no-leak errore, fence description org/group); 443 passati. E2e: errore generico su 404 (nessun body interno), richieste normali ok. Worker build ok.
38
+ - **Error reflection**: `makeCkanRequest` no longer embeds the upstream body (`JSON.stringify(decodedData)`) in the error returned to the caller — now a generic action-scoped message, with detail on stderr only (truncated). `worker.ts` catch-all: removed `error.message` from the JSON-RPC `data` field (server-side logging only). Closes the semi-blind read channel of the SSRF.
39
+ - **postMessage UI** (`resources/datastore-table-ui.ts`): the host origin is pinned from the reply to the `ui/initialize` handshake; messages carrying data are accepted only from that origin, and outbound messages use an explicit target origin (never `'*'`).
40
+ - **Prompt injection on org/group**: extended the c499 containment to the `organization.ts` and `group.ts` renderers — `description` in an untrusted block (`wrapUntrusted`), newlines collapsed in lists.
41
+ - 3 new tests (error no-leak, org/group description fencing); 443 passing. E2e: generic error on 404 (no internal body), normal requests fine. Worker build fine.
13
42
 
14
43
  ### v0.4.111
15
44
 
16
- Security — Giro 2: tre bug distinti economici.
45
+ Security — Round 2: three distinct low-cost bugs.
17
46
 
18
- - **MQA allowlist bypass** (`isValidMqaServer`, `tools/quality.ts`): regex non ancorata sostituita da URL-parse + confronto host esatto (`dati.gov.it`/`www.dati.gov.it`). Ora i trucchi suffix (`dati.gov.it.attacker.com`) e userinfo (`dati.gov.it@attacker.com`) sono rifiutati.
19
- - **Decompression bomb / unbounded buffering** (`utils/http.ts`): cap dimensione risposta (`maxContentLength`/`maxBodyLength` su axios + check byte su `arrayBuffer` nel branch fetch, def 32MB) e cap output decompressione (`maxOutputLength` su gunzip/brotli/inflateSync + check su DecompressionStream, def 64MB). Override via `CKAN_MAX_RESPONSE_BYTES`/`CKAN_MAX_DECOMPRESSED_BYTES`. Stop a OOM/stallo da payload iper-compressi.
20
- - **Cache-key collision** (`utils/cache.ts`): `canonicalizeParams` ora produce JSON canonico tipizzato (sort ricorsivo) invece di `k=v` join con `&` non-escapati; `buildCacheKey` incornicia in `JSON.stringify([url,action,canon])`. `{q:"budget",rows:10}` e `{q:"budget&rows=10"}` non collidono più.
21
- - 2 nuovi test (+regressioni aggiornate); 440 passati. E2e: MQA host valido raggiunge data.europa.eu, bypass rifiutato, richieste normali ok. Worker build ok.
22
- - Ulteriore hardening in lavorazione nei prossimi rilasci.
47
+ - **MQA allowlist bypass** (`isValidMqaServer`, `tools/quality.ts`): unanchored regex replaced by URL parsing plus exact host comparison (`dati.gov.it`/`www.dati.gov.it`). Suffix (`dati.gov.it.attacker.com`) and userinfo (`dati.gov.it@attacker.com`) tricks are now rejected.
48
+ - **Decompression bomb / unbounded buffering** (`utils/http.ts`): response size cap (`maxContentLength`/`maxBodyLength` on axios plus a byte check on `arrayBuffer` in the fetch branch, default 32MB) and decompression output cap (`maxOutputLength` on gunzip/brotli/inflateSync plus a check on DecompressionStream, default 64MB). Override via `CKAN_MAX_RESPONSE_BYTES`/`CKAN_MAX_DECOMPRESSED_BYTES`. Stops OOM/stalls from hyper-compressed payloads.
49
+ - **Cache-key collision** (`utils/cache.ts`): `canonicalizeParams` now produces typed canonical JSON (recursive sort) instead of a `k=v` join with unescaped `&`; `buildCacheKey` frames it in `JSON.stringify([url,action,canon])`. `{q:"budget",rows:10}` and `{q:"budget&rows=10"}` no longer collide.
50
+ - 2 new tests (plus updated regressions); 440 passing. E2e: valid MQA host reaches data.europa.eu, bypass rejected, normal requests fine. Worker build fine.
51
+ - Further hardening in progress for upcoming releases.
23
52
 
24
53
  ### v0.4.110
25
54
 
26
- Security — Giro 1: cluster SSRF su path `fetch` (GHSA-vmrr, GHSA-38f8; GHSA-8hxx chiarito):
55
+ Security — Round 1: SSRF cluster on the `fetch` path (GHSA-vmrr, GHSA-38f8; GHSA-8hxx clarified):
27
56
 
28
- - **`safeFetch()` centralizzato** in `utils/http.ts`: `redirect:"manual"` + ri-validazione di ogni hop (`validateServerUrl` + `assertHostnameResolvesSafe`), bounded hops, opzione `httpsOnly`. Chiude il redirect-SSRF (es. endpoint pubblico che fa 302 verso `169.254.169.254`) senza rompere i redirect canonici legittimi. Usato da `sparql_query` (3 fetch) e dal fetch MQA metrics in `quality.ts`.
29
- - **`assertHostnameResolvesSafe` ora fail-closed**: distingue "modulo DNS assente (Workers) → no-op" da "risoluzione fallita → throw". Prima un errore DNS lasciava proseguire (fail-open / TOCTOU).
30
- - **GHSA-8hxx**: verificato che WHATWG `URL` normalizza già gli encoding IPv4 (int/hex/ottale/short) a dotted-decimal prima di `validateServerUrl` → il check esistente li copre già. Nessun codice aggiunto (la PoC dell'advisory testava la regex sulle stringhe grezze, non sull'hostname parsato). Aggiunto solo un commento e test di regressione. Residuo `::7f00:1` (IPv4-compatible IPv6) non instradabile, come ammesso dall'advisory stesso.
31
- - MQA fetch: host costante (`data.europa.eu`), quindi hardening per coerenza, non vettore reale.
32
- - 6 nuovi test (redirect→interno bloccato, redirect→non-HTTPS rifiutato, fail-closed su DNS error, IPv4-encoding bloccati). 438 passati. E2e: Wikidata via `safeFetch` ok, IP interno bloccato. Worker build ok.
33
- - Ulteriore hardening di sicurezza in lavorazione nei prossimi rilasci.
57
+ - **Centralized `safeFetch()`** in `utils/http.ts`: `redirect:"manual"` plus re-validation of every hop (`validateServerUrl` + `assertHostnameResolvesSafe`), bounded hops, `httpsOnly` option. Closes redirect-SSRF (e.g. a public endpoint 302-ing to `169.254.169.254`) without breaking legitimate canonical redirects. Used by `sparql_query` (3 fetches) and by the MQA metrics fetch in `quality.ts`.
58
+ - **`assertHostnameResolvesSafe` is now fail-closed**: it distinguishes "DNS module absent (Workers) → no-op" from "resolution failed → throw". Previously a DNS error let the request proceed (fail-open / TOCTOU).
59
+ - **GHSA-8hxx**: verified that WHATWG `URL` already normalizes IPv4 encodings (int/hex/octal/short) to dotted-decimal before `validateServerUrl` → the existing check already covers them. No code added (the advisory PoC tested the regex against raw strings, not the parsed hostname). Only a comment and regression tests. The remaining `::7f00:1` (IPv4-compatible IPv6) is not routable, as the advisory itself concedes.
60
+ - MQA fetch: constant host (`data.europa.eu`), so hardening for consistency rather than a real vector.
61
+ - 6 new tests (redirect→internal blocked, redirect→non-HTTPS rejected, fail-closed on DNS error, IPv4 encodings blocked). 438 passing. E2e: Wikidata via `safeFetch` fine, internal IP blocked. Worker build fine.
62
+ - Further security hardening in progress for upcoming releases.
34
63
 
35
64
  ### v0.4.109
36
65
 
37
- Security hardening — 3 advisories, "veleno + porta" (rischio ambientale prima dei coltelli):
66
+ Security hardening — 3 advisories, "poison and door" (environmental risk before the knives):
38
67
 
39
- - **GHSA-3369** (second-order SSRF, `ckan_list_resources`): source-portal probing è ora **opt-in** (`check_source_portal` default `false`). Prima era ON: elencare le risorse di un dataset faceva contattare host/porte presi dai dati del dataset (confused-deputy + port-scan oracle + amplificazione via `Promise.all`). Aggiunto: drop delle porte ≠80/443 in `extractSourcePortal` (usa `hostname`, non `host`), cap del fan-out a 10 probe.
40
- - **GHSA-c499** (indirect prompt injection): campi liberi del portale (`notes`, resource `description`) resi verbatim nell'output. Ora avvolti in un blocco `untrusted` delimitato con avviso (`wrapUntrusted`), fence interne neutralizzate; URL portale validati per scheme (solo http/https) e resi in inline-code (`safeUrlText`); celle tabella di `ckan_list_resources` neutralizzate (`|`, newline). Contenimento, non fix totale — documentato agli integratori.
41
- - **GHSA-v3j5** (HTTP transport esposto): bind **`127.0.0.1`** di default (era `0.0.0.0`), `enableDnsRebindingProtection` + `allowedHosts`/`allowedOrigins`. `docker-compose.yml` pubblica su `127.0.0.1:3000:3000` (+ `CKAN_HTTP_HOST=0.0.0.0` dentro il container). Nuove env: `CKAN_HTTP_HOST`, `CKAN_HTTP_ALLOWED_HOSTS`, `CKAN_HTTP_ALLOWED_ORIGINS`. Chiudere questa porta declassa l'intero cluster SSRF da "remoto" a "locale".
42
- - 4 nuovi test; 432 passati. Verificato e2e su deployment HTTP reale (bind loopback, 403 su Host non consentito, no-probe di default, notes fenced). Worker: build ok.
43
- - Docs: README (tabella env HTTP), SKILL (source-portal ora opt-in), docker/README, docker-compose.
44
- - **Non ancora fatti** (cluster SSRF fetch, MQA regex, cache, decompression bomb, postMessage UI): coltelli e hardening, in giri successivi.
68
+ - **GHSA-3369** (second-order SSRF, `ckan_list_resources`): source-portal probing is now **opt-in** (`check_source_portal` defaults to `false`). It used to be ON: listing a dataset's resources contacted hosts and ports taken from the dataset's own data (confused deputy + port-scan oracle + amplification via `Promise.all`). Added: ports other than 80/443 dropped in `extractSourcePortal` (uses `hostname`, not `host`), fan-out capped at 10 probes.
69
+ - **GHSA-c499** (indirect prompt injection): free-text portal fields (`notes`, resource `description`) were rendered verbatim in the output. They are now wrapped in a delimited `untrusted` block with a warning (`wrapUntrusted`), with inner fences neutralized; portal URLs are scheme-validated (http/https only) and rendered as inline code (`safeUrlText`); `ckan_list_resources` table cells are neutralized (`|`, newlines). Containment, not a complete fix — documented for integrators.
70
+ - **GHSA-v3j5** (exposed HTTP transport): binds to **`127.0.0.1`** by default (was `0.0.0.0`), `enableDnsRebindingProtection` plus `allowedHosts`/`allowedOrigins`. `docker-compose.yml` publishes on `127.0.0.1:3000:3000` (with `CKAN_HTTP_HOST=0.0.0.0` inside the container). New env vars: `CKAN_HTTP_HOST`, `CKAN_HTTP_ALLOWED_HOSTS`, `CKAN_HTTP_ALLOWED_ORIGINS`. Closing this door downgrades the whole SSRF cluster from "remote" to "local".
71
+ - 4 new tests; 432 passing. Verified e2e against a real HTTP deployment (loopback bind, 403 on disallowed Host, no probing by default, notes fenced). Worker build fine.
72
+ - Docs: README (HTTP env var table), SKILL (source-portal now opt-in), docker/README, docker-compose.
73
+ - **Not done yet** (fetch SSRF cluster, MQA regex, cache, decompression bomb, postMessage UI): knives and hardening, in later rounds.
45
74
 
46
75
  ## 2026-06-22
47
76