Skip to content
IBMGHSA-55gx-p285-xw7f

Cross-tenant read of plugin-binding policies (BOLA) in ContextForge MCP Gateway

HighPublished Aug 25, 2026

### Summary: Any authenticated, non-admin tenant in a ContextForge deployment can read every other team's plugin-binding policies, including the full `config` dictionary (which routinely holds plugin secrets) and the creator's email. The four GET routes that list plugin bindings call the service-layer `list_bindings()` without the caller's team set, so they return bindings belonging to teams the caller has no membership in. The POST and DELETE handlers on the same routers do enforce team membership; only the reads were left unscoped. ### Details: Bindings are team-scoped resources. The two plugin-binding routers expose a list route and a `/{team_id}` route, both guarded only by the `tools.read` permission that any team member holds: ```python # mcpgateway/routers/a2a_agent_plugin_bindings.py @router.get("/") @require_permission("tools.read") async def list_a2a_agent_plugin_bindings(...): bindings, total = _service.list_bindings(db, team_id=None, ...) # no allowed_teams @router.get("/{team_id}") @require_permission("tools.read") async def list_a2a_agent_plugin_bindings_for_team(team_id: str, ...): bindings, total = _service.list_bindings(db, team_id=team_id, ...)...

GitHub advisory

Affected versions

PackageAffectedFixed in
mcp-contextforge-gateway
PyPI
< v1.0.5v1.0.5
Details and references

### Summary: Any authenticated, non-admin tenant in a ContextForge deployment can read every other team's plugin-binding policies, including the full `config` dictionary (which routinely holds plugin secrets) and the creator's email. The four GET routes that list plugin bindings call the service-layer `list_bindings()` without the caller's team set, so they return bindings belonging to teams the caller has no membership in. The POST and DELETE handlers on the same routers do enforce team membership; only the reads were left unscoped. ### Details: Bindings are team-scoped resources. The two plugin-binding routers expose a list route and a `/{team_id}` route, both guarded only by the `tools.read` permission that any team member holds: ```python # mcpgateway/routers/a2a_agent_plugin_bindings.py @router.get("/") @require_permission("tools.read") async def list_a2a_agent_plugin_bindings(...): bindings, total = _service.list_bindings(db, team_id=None, ...) # no allowed_teams @router.get("/{team_id}") @require_permission("tools.read") async def list_a2a_agent_plugin_bindings_for_team(team_id: str, ...): bindings, total = _service.list_bindings(db, team_id=team_id, ...) # team_id is caller-supplied ``` The service method takes no `allowed_teams` parameter at all (`a2a_agent_plugin_binding_service.py::list_bindings`), so it filters only by the optional caller-controlled `team_id`. The mutation paths on this same router derive `allowed_teams` from the caller and reject cross-team writes; the read method was simply never given that parameter. `tool_plugin_bindings.py`, service `tool_plugin_binding_service`) is identical. The response schema serializes `config: Dict[str, Any]` verbatim alongside `created_by`/`updated_by`, so a cross-tenant read exposes both arbitrary plugin configuration and tenant-user emails. The `/{team_id}` route is the sharp edge: the attacker names the victim team directly in the URL and the handler honors it with no membership check. ### Environment: ContextForge MCP Gateway v1.0.2, default configuration. Reachable by any authenticated non-admin user who is a member of at least one team and carries the default `tools.read` permission. No membership in the victim team is required. Team IDs are UUIDs already visible in many list responses, so targeting a specific team is trivial. This breaks a boundary the project documents as enforced. The multi-tenancy architecture page states the token-scoping invariant that "All list endpoints pass `token_teams` to the service layer," and lists as a critical implementation point that "Every resource operation must validate team membership." Plugin bindings are purely team-scoped: the `A2AAgentPluginBinding` / `ToolPluginBinding` models carry a non-nullable `team_id` and no public-visibility field, so no tier exists in which a cross-team read is intended. These two read routes are the one set of list endpoints that never pass the caller's team set. ### POC: The PoC plants a unique sentinel in one team's binding and reads it back as a tenant who belongs only to a different team. Run the self-contained script against a stock local v1.0.2 instance on `127.0.0.1:4444` (default config, `JWT_SECRET_KEY` known to the lab so tenant tokens can be minted): ``` cd /tmp/mcp-context-forge .venv/bin/python poc_plugin_binding_bola.py ``` poc_plugin_binding_bola.py ```python #!/usr/bin/env python3 import json, subprocess, sys, secrets, http.client GW="127.0.0.1"; PORT=4444; SECRET="pb-research-secret-key-2026" ADMIN_EMAIL="admin@example.com" SENTINEL="PB-SENTINEL-"+secrets.token_hex(6) ATTACKER_EMAIL="attacker-"+secrets.token_hex(3)+"@pbtenant.io" ATTACKER_PW="Attacker#Pass2026!" def mint(args): out=subprocess.run([".venv/bin/python","-m","mcpgateway.utils.create_jwt_token","-s",SECRET,"-e","100000"]+args, capture_output=True,text=True,cwd=".") return out.stdout.strip().splitli

Severity from
GitHub (reviewed advisory)
Weakness
CWE-639

More IBM advisories

All IBM

Critical advisories by email

Wednesdays: the week’s critical and high advisories in the AI and data stack, with the fixed versions. Only in weeks that have some.

Double opt-in. Unsubscribe any time.