HaiND MCP coordinated wave (5 GHSAs): scope checks that authorize names, tools that reroute, and guards on the wrong sibling¶
Wave in one line: Five MCP-era servers, five reusable boundary audits — authorization that validates what an expression mentions instead of what it selects, a tool parameter that smuggles the request onto a different API route, a user route missing the gate its admin twin enforces, a bearer token that never runs for a local open(), and an SSRF guard wired to every sink except the one an agent calls.
Signal: GitHub published five coordinated-disclosure advisories at 2026-10-10T18:31Z (all via VulnCheck, all with pinned-source writeups by the same researcher): GHSA-w2j7-xxh9-9q67 (1MCP Agent, CVE-2026-108586), GHSA-f7cg-qc77-hh95 (argocd-mcp, CVE-2026-108585), GHSA-c344-c8vm-8rxv (TencentCloud Octop, CVE-2026-108581 high), GHSA-29j6-3xvc-79ff (GenOffice, CVE-2026-108582), GHSA-rc65-6pgw-cw9c (zotero-mcp, CVE-2026-108583). Each ships a full root-cause writeup with pinned commits — these fix diffs are attack maps for the whole product class.
Advisory table¶
| GHSA | CVE | Product | Boundary |
|---|---|---|---|
| w2j7 | CVE-2026-108586 | 1MCP Agent @1mcp/agent 0.20.0–0.39.0 | OAuth tag-scope check validates the leaf tags mentioned in an advanced boolean tag-filter, never the set the expression selects. A token granted only tag:internal sends tag-filter=not internal → scope check passes (leaf internal is granted) → byTagExpression keeps every backend that does NOT carry internal → out-of-scope servers listed and invocable (the invocation guard reuses the same poisoned visibility set). !internal / -internal parse identically. |
| f7cg | CVE-2026-108585 | argocd-mcp ≤0.9.0 | delete_application concatenates applicationName into /api/v1/applications/${name} with no encoding; new URL(path, base) collapses dot-segments, so applicationName="../repositories/<urlencoded repo>" becomes DELETE /api/v1/repositories/<repo> with the server's Argo CD bearer token attached. Tool contract broken: a caller granted "delete applications" drives the server token to the repositories collection (and other /api/v1/ DELETE routes). Registered by default whenever MCP_READ_ONLY isn't true. |
| c344 | CVE-2026-108581 | TencentCloud Octop ≤1.0.2b6 | GET /api/providers gated by Depends(current_user) (JWT only) while its sibling GET /api/admin/providers uses require_permission("providers") — and the shared serializer emits "api_key": r.api_key unmasked against a global SELECT *. Any authenticated non-admin reads every admin-configured LLM provider key (OpenAI/Anthropic spend-able off-platform). Negative control by design: the same token is 403'd on the admin route. |
| 29j6 | CVE-2026-108582 | GenOffice ≤0.11.505 | HTTP MCP file store under world-listable /tmp/genoffice-mcp-http-* created with mkdirSync/createWriteStream with no mode → 0755/0644 under default umask. Any second local UID reads other clients' uploads with cat; the bearer token gates HTTP routes only and is never consulted for a local open(). Runtime-verified cross-UID read; umask 077 negative control. |
| rc65 | CVE-2026-108583 | zotero-mcp 0.10.0–0.14.1 | zotero_add_by_url generic webpage branch calls plain requests.get(url) — no private-address check, default redirect following — while the project's own _url_resolves_to_public_host + per-hop _guarded_pdf_get guard is deliberately wired into the sibling PDF sinks (its docstring even names "prompt injection steering"). Blind, metadata-only SSRF: parsed citation fields from internal responses persist to the library and read back via zotero_get_item_metadata. |
Why this is durable operator value¶
- Scope gates on expression mentions are not scope gates — test the complement. The 1MCP defect composes three locally-reasonable decisions: leaf-tag extraction for authorization, granted-tag enforcement only in the no-filter mode, and an advanced filter that never intersects the grant. The probe generalizes to every product with a boolean/filter DSL over authorization-relevant labels (tag filters, label selectors, search facets, GraphQL where-clauses, OData
$filter): send a filter that names only in-scope values but selects out-of-scope ones —not <granted>,!x,-x,granted OR *, negatedNOT EXISTS, complement sets. The decision table (no-filter pinned? direct out-of-scope denied? negated-filter selects out-of-scope?) isolates it in three requests. Second axis: listing visibility and invocation authorization computed from the same rebuildable filter — if the filter is the only ceiling, poisoning it at query time bypasses both gates at once. Invocation must be re-authorized independently of listing. - MCP/HTTP-wrapper tool parameters that splice into URL paths are route smugglers.
new URL(rel, base)applies RFC 3986 dot-segment normalization after string interpolation, so any tool parameter concatenated into a path selects the backend route for the caller — verb and privileged token included. Audit rule for every MCP server, SDK wrapper, or BFF that fronts an authenticated API: grep the client for template-literal path construction, then pass../<other-collection>/xas the parameter and record which route the sink sees. Prove with a local recording sink (the writeup's 20-line Node harness) — no live backend needed. This is the same parser-ordering genealogy asStripPrefix-before-normalization (Meta Ads/Traefic batch) and Argo CD's SSH-proxy cmdi (May 4 page): the trusted component builds a request the tool never advertised. - Sibling-route gate diffing finds credential oracles in minutes. Octop already contained the correct
require_permission("providers")— applied on the admin route, absent on the user route, with a shared unmasked serializer and a global table. The operator pattern: for any product with an/api/admin/X(or/api/X/admin) family, request/api/Xwith the lowest-tier token and diff response shapes against the admin twin. Byte-identical bodies + different dependency = missing authorization; a raw secret field in the shared serializer = credential oracle. Every LLM-gateway/control-plane that stores provider API keys centrally (Octop, LiteLLM-class, one-api-class) deserves this exact two-request probe with a throwaway account. - A bearer token that lives one layer above the filesystem guards nothing. GenOffice's store root name was random but listable, modes inherited the umask, and the second-UID read never entered the HTTP server. This is the shared-temp genealogy (April 30 OTLP page, lz4-java mcr4) with a new deployment shape: standalone HTTP MCP servers on shared dev boxes, jump hosts, and build agents. Recon probe as any local user:
ls -la /tmp/<tool>-*-http-*/uploads/*/and read anything0644; poll loops harvest for the server's lifetime. The general check is mode-inheritance auditing: any service that places per-session data under a world-searchable base with modelessmkdirSync/createWriteStream(Node defaults0o777/0o666masked by umask) is exposing it to every co-tenant UID regardless of how strong its network auth is. - Guard-presence asymmetry between sibling sinks is a fingerprint you can grep for. zotero-mcp's guard function names the threat in its docstring and is wired into two PDF sinks; the generic-metadata sink is the one missed sibling. Audit rule for any agent-facing fetch tool with multiple code paths (DOI vs arXiv vs generic, image vs PDF vs page): locate the project's own guard helper (
_url_resolves_to_public_host,_safe_get,assert_public), then grep everyrequests.get/fetch/urlopencall site and enumerate which are wrapped. Unguarded siblings are near-guaranteed; also check the readback channel — here, internal-response citation fields persist to a readable library, which upgrades blind SSRF to metadata exfil. Per-hop redirect re-validation (allow_redirects=False+ manual loop) is the second gap to check on every guard that is present — the FFmpeg RTSPLocationleg (July 30 URL-policy page) is the same first-hop-only bug.
Replayable validation boundaries (lab/authorized scope only)¶
- Negated-filter scope probe (own 1MCP-class lab tenant): with a token granted exactly one tag, issue the three-request decision table: unfiltered list (expect pinned), direct out-of-scope tag (expect deny),
not <granted-tag>list + one inert tool invocation (marker tool likepingonly). Evidence = selected-server sets per request. Never probe third-party aggregators without scope. - Path-parameter smuggle sweep (local sink only): run the tool client against a loopback recorder and pass
../<other-route>/probefor every string parameter that reaches a URL; table the observed final path + whether the service token header traveled. Never replay smuggled paths against a live Argo CD/GitLab/etc. instance without written scope. - Sibling-gate diff (owned instance, throwaway account): one low-priv token, both routes, response-shape diff. Evidence = field-presence table; redact any secret values found and report the field name, not the value.
- Co-tenant temp-read check (owned multi-UID lab box): second UID
ls/caton the tool's/tmpprefix; mode dump withstat. This is a local posture audit, not a remote technique. - Sibling-fetch guard inventory (source-level): grep the guard helper's call sites vs raw-fetch call sites; prove the gap against an owned loopback metadata stub returning one distinctive tag, per the writeup's repro pattern. No cloud-metadata endpoints, no third-party internal ranges.
Related pages¶
- May 4 Argo Workflows/Argo CD boundary batch — Argo CD ecosystem fold for the argocd-mcp smuggling leg.
- April 30 OTLP shared-temp blob injection — shared-temp mode-inheritance genealogy for the GenOffice leg.
- July 30 URL-policy page (first-hop-only guards), Sept 18 ignored-scope page (scope-vs-resource drift), Oct 9 Vikunja page (scope-minting ladder) — same audit families from different directions.