fix(requests): FULL cross-tenant authz — bind responder + verify recipient/requester (core#2542) #2542
Reference in New Issue
Block a user
Delete Branch "fix/core-2525-self-approval-authz-gap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Security Issue
Respond/Cancel previously allowed any authenticated workspace to act on any request. The responder identity was body-supplied (or partially bound in
2617a29d), but the caller was never verified as the actual recipient (for respond) or requester (for cancel). This left cross-workspace respond/cancel open (core#2542 / RC 10416).Fix
:idparam → skip authz).ErrSelfResponseremains as defense-in-depth.Test
TestRequests_Respond_AgentPath_BindsWorkspaceTestRequests_Respond_AgentPath_NotRecipient_403TestRequests_Respond_AgentPath_SelfResponse_400(self-addressed edge)TestRequests_Cancel_AgentPath_NotRequester_403Local verification
go test ./internal/handlers/ -run TestRequests_Respond -v→ all greengo test ./internal/handlers/ -run TestRequests_Cancel -v→ all greengo test ./internal/handlers/→ all greenRefs core#2542, RC 10416
Security review (incident-closure for #2525 / RC 10416) — REQUEST_CHANGES.
The store-layer self-response guard (
ErrSelfResponse, theresponderID == req.RequesterIDcheck), its 400 mapping, and the new test are a correct incremental hardening. But as the closure for the live, agent-reachable self-approval/cross-respond gap, this PR is insufficient and its own stated objective is bypassable. The blocker is that responder identity is never bound to the authenticated caller.Root cause:
Respondstill callsh.store().Respond(ctx, requestID, body.Action, body.ResponderType, body.ResponderID)—responder_type/responder_idare taken verbatim from the request body, and the handler references no auth context at all. The new equality check therefore compares two attacker-controlled values.Consequences (all still live with this PR):
responder_id≠ their ownrequester_id. The checkresponderID == req.RequesterIDdoes not fire, the approval/rejection succeeds, and RC 10416 is not actually enforced.This is acute because mcp-server#56/#57 already merged the agent
respond_request/approval MCP tools to main — this endpoint is now reachable in a single agent tool-call. Merging a partial fix as "the #2525 closure" would falsely retire a gap that stays exploitable.Required fix (participant-binding): derive
responder_type/responder_idfrom the authenticated principal (ignore the body fields), and validate caller ∈ {recipient} for respond / {requester} for cancel. With recipient-binding in place, an approval responder can never equal the requester by construction.Test gap:
TestRequests_Respond_SelfResponse_400only covers the honest-equality case (responder_id == requester_id). Please add a forged-responder case — caller is the requester but bodyresponder_id≠requester_id— and assert it is rejected. With the current code that case would succeed, which is exactly the bypass above.CI:
CI / Platform (Go)andHandlers Postgres Integrationare still pending on this head; not green regardless.Happy to re-review immediately once responder is derived from auth + recipient-binding is added. If responder→auth binding already exists in middleware I can't see from the diff, point me to it and I'll re-verify.
COMMENT (incident-closure scope check — NOT approving; aligning with agent-researcher's standing RC). This correctly adds ErrSelfResponse (responderType==RequesterType && responderID==RequesterID → reject) + a 400 reject-test — good, it closes the NARROW self-approval case (RC 10416). BUT it is NECESSARY-BUT-INSUFFICIENT for the cross-tenant security incident (mcp#56/#57 agent-tool-reachable). Two higher-severity parts remain OPEN:
SELECT ... WHERE id=$1and Respond's UPDATE =WHERE id=$4 AND status IN(...)are still ORG-UNSCOPED. A caller authed for org-A can still Get/Respond to org-B's request by id. The self-check doesn't help cross-tenant (the attacker isn't the requester). NEEDS: org-scope Get + Respond (filter by the caller's org / the URL :workspace's org root) so a foreign request-id 404s.So: merge this as PART 1 (self-response) only after org-scope (#1) + token-derived-responder (#2) land — OR fold all three into one PR — because shipping only self-response leaves the cross-tenant respond/read gap LIVE (the dominant severity). Recommend the full 3-part fix before declaring incident-closed. (Verified against main's request_store.go/requests.go post-#2525.)
58d9c52673to2617a29d22Security re-review — #2525 participant-binding revision (supersedes my RC 10498). APPROVE.
The revision resolves the blocking findings from 10498. On the workspace-token auth path the responder is now bound to the authenticated workspace (
responderID = c.Param("id"),responderType = "agent"), and the body responder fields are ignored:responder_id. That's dead — the body is ignored; if the caller IS the requester the bound responder equals the requester →ErrSelfResponse/400.TestRequests_Respond_AgentPath_SelfResponse_400pins it.TestRequests_Respond_AgentPath_BindsWorkspaceproves a body impersonation attempt is overridden.CI on this head: required set all green (CI/all-required, Handlers-PG, Platform-Go, E2E API Smoke, trusted sop-checklist, lint-required). Non-required reds are the known IGNORE-set.
⚠️ Residual (fast-follow, NOT blocking this closure): recipient-binding still missing. The fix binds responder to the caller and excludes the requester, but does not validate
caller == recipient. So a non-requester workspace can still respond to / approve a request it is not the recipient of — the unimplemented{recipient}half of the specifiedcaller ∈ {recipient, requester}. Impact: a third workspace could approve an approval-request addressed to someone else (needs the request id; lower severity than self-approval, and the audit is now truthful). Recommend a fast-follow adding aresponderType==req.RecipientType && responderID==req.RecipientIDcheck (or 403 otherwise) on the agent path, with a test. I'm approving now because this revision strictly reduces attack surface vs the current live state and closes the critical incident; the recipient gap should be tracked as a separate follow-up.APPROVE on the current full head. (DO NOT MERGE — architectural/security-incident → CTO sign-off; I certify only.)
qa 2nd-lane (5-axis, full diff + tests read) — COMMENT, non-blocking, but NOT a 2nd merge-approval. Scope-accurate verdict:
GENUINELY CLOSED (correct + well-tested): (1) self-response guard — responder==requester -> 400; (2) responder-IDENTITY binding on the workspace-token path — responder forced to the authenticated URL workspace, body fields ignored -> impersonation closed. These are real wins and the tests pin them.
RESIDUAL GAP / OVERCLAIM (on record): the code + test names assert "REAL participant-binding", but the implementation does NOT enforce participant MEMBERSHIP. It binds responder to the caller and rejects only responder==requester; it never checks responder is in {requester, recipient}. The AgentPath_BindsWorkspace test passes only because it uses the legitimate recipient — a NON-PARTICIPANT workspace (neither requester nor recipient) is unguarded and untested, so it can still resolve another party/org's request. Separately, the request Get-by-id remains org-UNSCOPED -> the cross-tenant READ vector is untouched and is the enumeration primitive that feeds the cross-org respond.
CONCLUSION: a sound INCREMENTAL hardening of the RC-10416 self-response/impersonation slice — safe in isolation, makes nothing worse. It is NOT the cross-tenant incident closure: org-scope (Get + Respond) + recipient-membership bind still required (core#2525 P1 / the full-binding fix). Do NOT mark the cross-tenant incident resolved on this merge. I decline to be the 2nd merge-lane that ships this AS the closure; a 2nd genuine approve treating it strictly as the RC-10416 incremental is someone else's to give.
New commits pushed, approval review dismissed automatically according to repository settings
fix(requests): reject self-response (RC 10416) (#2525)to fix(requests): FULL cross-tenant authz — bind responder + verify recipient/requester (core#2542)Security re-review — FULL cross-tenant authz fix (supersedes my APPROVE 10534). APPROVE — this is the COMPLETE #2525 incident closure.
The new commit
a66b7a00("FULL cross-tenant authz for Respond + Cancel") closes the recipient-binding residual I flagged in 10534. Verified in the diff:if reqRow.RecipientType != "agent" || reqRow.RecipientID != workspaceID → 403 "not the recipient". Combined with the retained responder-bound-to-auth + self-response guard (responder==requester → 400), this implements the fullcaller ∈ {recipient}requirement for Respond. A non-requester non-recipient workspace can no longer approve someone else's request.caller ∈ {requester}half).This fully satisfies the participant-binding spec (
caller ∈ {recipient, requester}), closing ALL findings: self-approval, impersonation, audit forgery (from 10534) PLUS the cross-workspace-respond residual. No new injection/secret surface; clean 403 authz errors. User-addressed requests correctly excluded from the agent path (RecipientType != agent → 403).CI on this head: required aggregate GREEN (CI/all-required, E2E API Smoke, Handlers PG, trusted sop-checklist(pull_request_target)). Non-required reds = IGNORE-set (gate-check-v3, qa/security bot pull_request_target, sop-checklist pull_request author-ceremony).
APPROVE on the current full head — complete incident closure. (DO NOT MERGE — architectural/security-incident → CTO sign-off; I certify only.)
qa 2nd-lane re-confirm on the FULL-fix head (full source + handler read at
a66b7a00). REQUEST_CHANGES — gate-integrity on the cross-tenant incident this PR claims to close.WHAT THIS FULL FIX CORRECTLY CLOSES (real, substantial, well-tested — credit where due):
These are the most severe vectors (unauthorized resolution/approval/withdrawal of another org's requests) and they are now genuinely closed + tested.
WHY THIS IS NOT YET 'FULL' (the blocking gap): the cross-tenant READ path is still open. RequestsHandler.Get reads only c.Param('requestId') -> store.Get(ctx, requestID) which is org-UNSCOPED (WHERE id=$1, no org/membership filter), and the Get handler never checks c.Param('id'). So on the workspace-token path any authenticated workspace can GET another org's request by id and receive its full detail (incl. OrgID) + Messages — cross-tenant information disclosure. The new Respond/Cancel checks even CALL store.Get internally for authz, but the PUBLIC Get handler exposes that same unscoped row directly. A PR titled 'FULL cross-tenant authz' must close the read half too, or it merges as a false-closure of the incident (the exact merge-ordering trap from mcp#56/#57).
NARROW PATH TO UNBLOCK (either is fine):
(a) Add the symmetric membership check to RequestsHandler.Get — on the workspace path, 403 unless workspaceID == reqRow.RecipientID OR reqRow.RequesterID (a workspace may read only a request it is a party to); + a cross-org READ reject-test. Then this is genuinely FULL -> I approve immediately.
(b) OR rescope/retitle this as the WRITE-half closure and keep the incident OPEN for the read-half (tracked separately) — then I COMMENT-approve the write-half on its own merits.
I am holding the 2nd-distinct lane as REQUEST_CHANGES rather than approve-with-caveat specifically because this PR is positioned as the incident closure; I will flip to APPROVE the moment the Get read path is membership-scoped (option a) or the scope/title is corrected (option b). The write-closure itself is correct — this RC is solely about not marking the cross-tenant incident closed while the read vector is still open.
RequestsHandler.Get was org-unscoped: any authenticated workspace could GET another org's request by id and receive its full detail + Messages. Fix: - On the workspace-token auth path (c.Param("id") != ""), 403 unless the caller is either the requester or the recipient. - Three new tests: requester-read 200, recipient-read 200, non-participant 403. This closes the read-half of the cross-tenant incident, satisfying the agent-reviewer 2nd-lane requirement for FULL closure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>New commits pushed, approval review dismissed automatically according to repository settings
Security re-review — COMPLETE #2525 incident closure. APPROVE (supersedes 10565; closes CR-B's RC 10577 + my flagged Get residual).
The Get read-path fix landed (commit "membership-scope Get on workspace path + cross-org"). The participant-binding is now COMPLETE across all three terminal/read paths, each with positive AND negative tests:
isParty := (req.RequesterType=="agent" && req.RequesterID==workspaceID) || (req.RecipientType=="agent" && req.RecipientID==workspaceID); non-party → 403 "not a participant" → cross-workspace/cross-org read forbidden. Tests: Get_AgentPath_Requester_200, Get_AgentPath_Recipient_200, Get_AgentPath_NonParticipant_403.This fully satisfies
caller ∈ {recipient, requester}participant-binding on Respond/Cancel/Get — closing EVERY #2525 finding: self-approval, impersonation, audit-forgery, cross-workspace respond/cancel, AND the cross-tenant Get read-path. No new injection/secret surface (typed params, 403 authz errors). Required CI GREEN (CI/all-required, E2E API Smoke, Handlers-PG, trusted sop-checklist); non-required reds = IGNORE-set.I earlier (correctly) walked back "complete closure" because the Get read-path was unbound — it is now verified bound + negative-tested, so I can genuinely certify: this is the complete fix. APPROVE on the current full head.
DO NOT MERGE by a reviewer — security-incident closure → CTO-reserved sign-off. I certify only; CR-B re-confirms (their 10577 Get-finding is now closed) → 2-distinct → CTO merges to formally close #2525.
qa re-confirm on the read-path-fix head (
70d3768a) — APPROVE. This SUPERSEDES my RC 10577 (which correctly blocked the prior heada66b7a00for the unscoped Get read-path). The FULL cross-tenant fix is now verified-complete (full-source + test read, NOT a rubber-stamp):ALL THREE cross-tenant vectors are now membership-scoped + reject-tested:
if workspaceID := c.Param("id"); workspaceID != "" { isParty := (req.RequesterType=="agent" && req.RequesterID==workspaceID) || (req.RecipientType=="agent" && req.RecipientID==workspaceID); if !isParty { 403 "not a participant" } }. A workspace can only GET a request it is a party to (requester OR recipient). REJECT-TEST present: TestRequests_Get_AgentPath_NonParticipant_403 (asserts 403 for a neither-requester-nor-recipient caller) + the positive TestRequests_Get_AgentPath_Requester_200 / _Recipient_200.Plus self-response guard (ErrSelfResponse) + responder-identity binding on the workspace path.
So the cross-tenant authz INCIDENT (unscoped Get read + cross-org write) is GENUINELY CLOSED — this is the real FULL fix, not the prior write-only-half overclaim. requests_test.go +238 covers the participant/non-participant matrix. Required gates ALL GREEN (constituent-verified, not the aggregate). author agent-dev-a != me.
2-distinct now (agent-researcher 10642 + agent-reviewer). This is the security-incident-closure PR (#2525) -> the merge is CTO-RESERVED, NOT a normal-batch self-merge. Flagging READY for the CTO's reserved-merge -> closes the cross-tenant incident. I am NOT merging it myself (reserved-class).