Summary
The admin A2A-agent edit route does not enforce object ownership. An authenticated non-owner holding the common a2a.update permission can take over any A2A agent by its id: reassign ownership to themselves, redirect the agent's endpoint URL to attacker-controlled infrastructure, and relocate the agent into their own team, including across team/tenant boundaries. The equivalent API route PUT /a2a/{agent_id} and the admin delete route both correctly enforce ownership.
Affected code
POST /admin/a2a/{agent_id}/edit -> admin_edit_a2a_agent (mcpgateway/admin.py, ~line 16377) calls a2a_service.update_agent(...) without user_email.
- In
a2a_service.update_agent (mcpgateway/services/a2a_service.py, ~line 1424) the ownership check is gated by if user_email: -> check_resource_ownership. With user_email omitted the check is skipped, the agent is fetched by id alone, and every submitted field is applied via setattr.
- The handler sets
owner_email to the caller, so a successful edit reassigns ownership to the caller.
- Contrast:
PUT /a2a/{agent_id} (mcpgateway/main.py, ~line 5084) and the admin delete route both pass user_email and enforce ownership. This is an isolated omission on the admin edit route.
- The decorator
require_permission("a2a.update", allow_admin_bypass=False) only checks that the caller holds a2a.update; for a single-team-scoped API token it does not re-scope to the target agent's team, so a cross-team caller is not blocked at the decorator.
Proof of concept
Verified live on v1.0.6 (default multi-tenant: EMAIL_AUTH + RBAC + Teams, sqlite). All actors are non-admin users holding the built-in team developer role, which includes a2a.update. Setup: team T with userA (agent owner) and userB (member, not owner); team T2 with userC. userA creates an A2A agent in team T.
- Control (same-team non-owner, proper API path):
PUT /a2a/{id} as userB -> 403 {"detail":"Only the owner can update this agent"} (agent unchanged).
- Bug (same-team non-owner, admin route):
POST /admin/a2a/{id}/edit as userB with endpoint_url=https://attacker.example -> 200 {"success":true}. Database after: owner_email flips userA -> userB; endpoint_url -> attacker URL.
- Cross-team: mint a single-team-scoped API token for
userC (POST /tokens/teams/{T2}; the JWT teams claim = ["T2"] only). POST /admin/a2a/{id}/edit as userC with team_id=T2 -> 200. userA's agent (team T) is taken over: owner_email -> userC, endpoint_url -> attacker, and the agent is relocated into team T2. The same userC on PUT /a2a/{id} -> 403 {"detail":"Access denied"}.
The differential (the same non-owner is 403 on the API path but 200 on the admin path, with an observed database change) confirms the admin edit route skips the ownership check that the other routes enforce.
Impact
Broken object-level authorization / cross-tenant takeover of A2A agents. An authenticated user with a2a.update (a common team role), knowing a target agent's id, can seize ownership, redirect the agent's endpoint URL to attacker-controlled infrastructure (capturing passthrough headers/credentials routed to that agent), and relocate the agent into their own team, breaking team/tenant isolation.
Suggested remediation
Make admin_edit_a2a_agent pass user_email into a2a_service.update_agent so the ownership gate matches the PUT /a2a/{id} and admin delete routes. Additionally, scope the a2a.update permission check on the admin route to the target agent's team.
Summary
The admin A2A-agent edit route does not enforce object ownership. An authenticated non-owner holding the common
a2a.updatepermission can take over any A2A agent by its id: reassign ownership to themselves, redirect the agent's endpoint URL to attacker-controlled infrastructure, and relocate the agent into their own team, including across team/tenant boundaries. The equivalent API routePUT /a2a/{agent_id}and the admin delete route both correctly enforce ownership.Affected code
POST /admin/a2a/{agent_id}/edit->admin_edit_a2a_agent(mcpgateway/admin.py, ~line 16377) callsa2a_service.update_agent(...)withoutuser_email.a2a_service.update_agent(mcpgateway/services/a2a_service.py, ~line 1424) the ownership check is gated byif user_email:->check_resource_ownership. Withuser_emailomitted the check is skipped, the agent is fetched by id alone, and every submitted field is applied viasetattr.owner_emailto the caller, so a successful edit reassigns ownership to the caller.PUT /a2a/{agent_id}(mcpgateway/main.py, ~line 5084) and the admin delete route both passuser_emailand enforce ownership. This is an isolated omission on the admin edit route.require_permission("a2a.update", allow_admin_bypass=False)only checks that the caller holdsa2a.update; for a single-team-scoped API token it does not re-scope to the target agent's team, so a cross-team caller is not blocked at the decorator.Proof of concept
Verified live on v1.0.6 (default multi-tenant: EMAIL_AUTH + RBAC + Teams, sqlite). All actors are non-admin users holding the built-in team
developerrole, which includesa2a.update. Setup: team T withuserA(agent owner) anduserB(member, not owner); team T2 withuserC.userAcreates an A2A agent in team T.PUT /a2a/{id}asuserB->403 {"detail":"Only the owner can update this agent"}(agent unchanged).POST /admin/a2a/{id}/editasuserBwithendpoint_url=https://attacker.example->200 {"success":true}. Database after:owner_emailflipsuserA -> userB;endpoint_url-> attacker URL.userC(POST /tokens/teams/{T2}; the JWTteamsclaim =["T2"]only).POST /admin/a2a/{id}/editasuserCwithteam_id=T2->200.userA's agent (team T) is taken over:owner_email -> userC,endpoint_url -> attacker, and the agent is relocated into team T2. The sameuserConPUT /a2a/{id}->403 {"detail":"Access denied"}.The differential (the same non-owner is
403on the API path but200on the admin path, with an observed database change) confirms the admin edit route skips the ownership check that the other routes enforce.Impact
Broken object-level authorization / cross-tenant takeover of A2A agents. An authenticated user with
a2a.update(a common team role), knowing a target agent's id, can seize ownership, redirect the agent's endpoint URL to attacker-controlled infrastructure (capturing passthrough headers/credentials routed to that agent), and relocate the agent into their own team, breaking team/tenant isolation.Suggested remediation
Make
admin_edit_a2a_agentpassuser_emailintoa2a_service.update_agentso the ownership gate matches thePUT /a2a/{id}and admin delete routes. Additionally, scope thea2a.updatepermission check on the admin route to the target agent's team.