@aborruso/ckan-mcp-server 0.4.130 → 0.4.131

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 +14 -0
  2. package/dist/index.js +99 -83
  3. package/package.json +1 -1
package/LOG.md CHANGED
@@ -1,7 +1,21 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-10-22
4
+
5
+ ### v0.4.131
6
+
7
+ Ships #563: Anubis challenge retry with an honest User-Agent (#7). Tests 595 passed, smoke 17/17.
8
+
9
+ ### Anubis-fronted portals: retry once with an honest User-Agent (#7)
10
+
11
+ Reported by @umlwbin: our Chrome-impersonating headers (needed by dati.anticorruzione.it's WAF) trip Anubis anti-bot gates, which answer with an HTML proof-of-work page that axios cannot solve. Anubis challenges any UA containing `Mozilla`, including `Mozilla/5.0 (compatible; ...)`, so no fixed UA serves both portals. `makeCkanRequest` now detects the challenge page (`anubis_challenge` marker, `isAnubisChallenge`) and retries once with `CKAN-MCP-Server/1.0` and no `Sec-CH-UA*` headers, on both the axios and fetch/Workers paths. The retry acquires its own rate-limit token (Greptile review). +6 tests (fail without the fix). Tests 595 passed; e2e `package_search` OK on canwin-datahub.ad.umanitoba.ca/data (48,947), dati.anticorruzione.it (70), dati.gov.it.
12
+
3
13
  ## 2026-10-19
4
14
 
15
+ ### Advisory GHSA-w3gp-3hrh-v465 published
16
+
17
+ Reported by @olevasseur, accepted and published as Medium (CVSS 6.5): affected `>= 0.4.114, <= 0.4.129`, patched `0.4.130`. CVE requested, not yet assigned. v0.4.130 is live on npm (with provenance), the MCP Registry and the Worker. `SECURITY.md` table updated.
18
+
5
19
  ### v0.4.130
6
20
 
7
21
  Ships #561: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465). Tests 589 passed, smoke 17/17.
package/dist/index.js CHANGED
@@ -932,6 +932,11 @@ function auditLog(serverUrl, action, params, cacheHit) {
932
932
  } catch {
933
933
  }
934
934
  }
935
+ var BROWSER_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
936
+ var HONEST_USER_AGENT = "CKAN-MCP-Server/1.0";
937
+ function isAnubisChallenge(body) {
938
+ return typeof body === "string" && body.includes("anubis_challenge");
939
+ }
935
940
  async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
936
941
  const isNode = typeof process !== "undefined" && !!process.versions?.node;
937
942
  validateServerUrl(serverUrl);
@@ -963,95 +968,106 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
963
968
  _lastCacheHit = false;
964
969
  const rateLimitConfig = getRateLimitConfig();
965
970
  const rateLimitEnabled = rateLimitConfig.enabled && opts.rateLimit !== false;
966
- if (rateLimitEnabled) {
967
- const hostname = new URL(resolvedServerUrl).hostname;
968
- await getRateLimiter().acquire(hostname);
969
- }
971
+ const acquireRateToken = async () => {
972
+ if (rateLimitEnabled) {
973
+ const hostname = new URL(resolvedServerUrl).hostname;
974
+ await getRateLimiter().acquire(hostname);
975
+ }
976
+ };
977
+ await acquireRateToken();
970
978
  try {
971
- let decodedData;
972
- if (isNode) {
973
- const safeAgents = await getSafeAgents();
974
- const response = await axios.get(url, {
975
- params,
976
- timeout: 3e4,
977
- responseType: "arraybuffer",
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
- },
985
- // Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
986
- // target itself, bypassing the SSRF-safe lookup pinned in the agents below.
987
- proxy: false,
988
- maxContentLength: MAX_RESPONSE_BYTES,
989
- maxBodyLength: MAX_RESPONSE_BYTES,
990
- ...safeAgents ? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent } : {},
991
- headers: {
992
- Accept: "application/json, text/plain, */*",
993
- "Accept-Language": "en-US,en;q=0.9,it;q=0.8",
994
- "Accept-Encoding": "gzip, deflate, br",
995
- Connection: "keep-alive",
996
- "Sec-CH-UA": '"Chromium";v="120", "Not?A_Brand";v="24", "Google Chrome";v="120"',
997
- "Sec-CH-UA-Mobile": "?0",
998
- "Sec-CH-UA-Platform": '"Linux"',
999
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1000
- }
1001
- });
1002
- decodedData = await decodePossiblyCompressed(
1003
- response.data,
1004
- response.headers
1005
- );
1006
- } else {
1007
- const searchParams = new URLSearchParams();
1008
- for (const [key, value] of Object.entries(params)) {
1009
- if (value === void 0 || value === null) {
1010
- continue;
1011
- }
1012
- searchParams.set(key, String(value));
1013
- }
1014
- const fetchUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url;
1015
- const controller = new AbortController();
1016
- const timeoutId = setTimeout(() => controller.abort(), 3e4);
1017
- let response;
1018
- try {
1019
- response = await safeFetch(fetchUrl, {
1020
- method: "GET",
1021
- signal: controller.signal,
979
+ const fetchDecoded = async (honest) => {
980
+ if (isNode) {
981
+ const safeAgents = await getSafeAgents();
982
+ const response = await axios.get(url, {
983
+ params,
984
+ timeout: 3e4,
985
+ responseType: "arraybuffer",
986
+ maxRedirects: 5,
987
+ // Re-validate every redirect hop: Node skips the agent's `lookup` for IP
988
+ // literals, so a public host 302-ing to e.g. 169.254.169.254 would otherwise
989
+ // be followed. Also enforces CKAN_ALLOWED_DOMAINS on redirect targets.
990
+ beforeRedirect: (redirectOptions) => {
991
+ validateServerUrl(redirectOptions.href);
992
+ },
993
+ // Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
994
+ // target itself, bypassing the SSRF-safe lookup pinned in the agents below.
995
+ proxy: false,
996
+ maxContentLength: MAX_RESPONSE_BYTES,
997
+ maxBodyLength: MAX_RESPONSE_BYTES,
998
+ ...safeAgents ? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent } : {},
1022
999
  headers: {
1023
1000
  Accept: "application/json, text/plain, */*",
1024
- "Accept-Encoding": "identity",
1025
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1001
+ "Accept-Language": "en-US,en;q=0.9,it;q=0.8",
1002
+ "Accept-Encoding": "gzip, deflate, br",
1003
+ Connection: "keep-alive",
1004
+ ...honest ? {} : {
1005
+ "Sec-CH-UA": '"Chromium";v="120", "Not?A_Brand";v="24", "Google Chrome";v="120"',
1006
+ "Sec-CH-UA-Mobile": "?0",
1007
+ "Sec-CH-UA-Platform": '"Linux"'
1008
+ },
1009
+ "User-Agent": honest ? HONEST_USER_AGENT : BROWSER_USER_AGENT
1026
1010
  }
1027
- }, { maxHops: 5 });
1028
- } catch (error) {
1029
- if (controller.signal.aborted) {
1030
- throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1011
+ });
1012
+ return decodePossiblyCompressed(
1013
+ response.data,
1014
+ response.headers
1015
+ );
1016
+ } else {
1017
+ const searchParams = new URLSearchParams();
1018
+ for (const [key, value] of Object.entries(params)) {
1019
+ if (value === void 0 || value === null) {
1020
+ continue;
1021
+ }
1022
+ searchParams.set(key, String(value));
1031
1023
  }
1032
- if (error instanceof TypeError) {
1033
- throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1024
+ const fetchUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url;
1025
+ const controller = new AbortController();
1026
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
1027
+ let response;
1028
+ try {
1029
+ response = await safeFetch(fetchUrl, {
1030
+ method: "GET",
1031
+ signal: controller.signal,
1032
+ headers: {
1033
+ Accept: "application/json, text/plain, */*",
1034
+ "Accept-Encoding": "identity",
1035
+ "User-Agent": honest ? HONEST_USER_AGENT : BROWSER_USER_AGENT
1036
+ }
1037
+ }, { maxHops: 5 });
1038
+ } catch (error) {
1039
+ if (controller.signal.aborted) {
1040
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1041
+ }
1042
+ if (error instanceof TypeError) {
1043
+ throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1044
+ }
1045
+ throw error;
1046
+ } finally {
1047
+ clearTimeout(timeoutId);
1034
1048
  }
1035
- throw error;
1036
- } finally {
1037
- clearTimeout(timeoutId);
1038
- }
1039
- if (!response.ok) {
1040
- throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
1041
- }
1042
- const declaredLength = Number(response.headers.get("content-length"));
1043
- if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
1044
- throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action, serverUrl);
1045
- }
1046
- const buffer = await response.arrayBuffer();
1047
- if (buffer.byteLength > MAX_RESPONSE_BYTES) {
1048
- throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
1049
+ if (!response.ok) {
1050
+ throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
1051
+ }
1052
+ const declaredLength = Number(response.headers.get("content-length"));
1053
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
1054
+ throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action, serverUrl);
1055
+ }
1056
+ const buffer = await response.arrayBuffer();
1057
+ if (buffer.byteLength > MAX_RESPONSE_BYTES) {
1058
+ throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
1059
+ }
1060
+ const headers = {};
1061
+ response.headers.forEach((headerValue, headerKey) => {
1062
+ headers[headerKey] = headerValue;
1063
+ });
1064
+ return decodePossiblyCompressed(buffer, headers);
1049
1065
  }
1050
- const headers = {};
1051
- response.headers.forEach((headerValue, headerKey) => {
1052
- headers[headerKey] = headerValue;
1053
- });
1054
- decodedData = await decodePossiblyCompressed(buffer, headers);
1066
+ };
1067
+ let decodedData = await fetchDecoded(false);
1068
+ if (isAnubisChallenge(decodedData)) {
1069
+ await acquireRateToken();
1070
+ decodedData = await fetchDecoded(true);
1055
1071
  }
1056
1072
  if (decodedData && decodedData.success === true) {
1057
1073
  const result = decodedData.result;
@@ -6101,7 +6117,7 @@ var registerAllPrompts = (server) => {
6101
6117
  function createServer() {
6102
6118
  return new McpServer({
6103
6119
  name: "ckan-mcp-server",
6104
- version: "0.4.130"
6120
+ version: "0.4.131"
6105
6121
  });
6106
6122
  }
6107
6123
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.130",
3
+ "version": "0.4.131",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {