POST /api/activity/:activityId/approve and /reject carry no authorization beyond auth. Any authenticated user who knows an activity id can approve or reject an approval_needed activity in a pod they are not a member of.
Measured at origin/main 9ee1fac6d, not read off the source.
Reproduction
Caller is a member of no pod; the activity belongs to pod-1, whose createdBy and members are someone-else.
APPROVE status=200 approveCalled=1 body={"success":true,"status":"approved"}
CONTROL/create status=403 createCalled=0 body={"error":"Only pod members can create activities in a pod"}
The /create line is the positive control, run in the same harness against the same mocked pod and caller. It refuses and never reaches the model — so the harness can see a guard, and approve's 200 is the route's behaviour rather than a bypassed mock. activity.approve was called with the attacker's own userId, so the row records them as the approver.
Where the gap is
routes/activity.ts requires isPodMember and applies it on exactly two routes:
:231 — POST /seed/:podId
:258 — POST /create
:197 /:activityId/approve and :211 /:activityId/reject are auth-only. The service behind them does not compensate: ActivityService.approveActivity / rejectActivity do Activity.findById, check activity.type !== 'approval_needed', and call activity.approve(userId, notes). Neither loads the pod.
__tests__/unit/routes/activity.write-membership.test.js covers /create and /seed/:podId only — the file's own header names the create hole as the one that mattered, and the approve/reject pair was not in that sweep.
Why it matters
This is the resolve verb for ADR-020 cards, whose D3 states cards are actionable by the workspace owner only, and TASK-095 v1.1 is currently being specced with resolveApproval as the single resolve verb for clickable options. A ruling surface that anyone can press is worth closing before more UI points at it.
Not caused by, but adjacent to, #1451
#1451 is a correct fix to a separate dead-predicate bug in getPendingApprovals and should not be held for this. It does widen which users see pending approvals (creator-only → creator + members), which surfaces more activity ids to more people; those people are legitimate pod members, so it is exposure rather than escalation. The hole here is independent of it and predates it.
Suggested fix
Load the activity's pod and apply the same isPodMember check the sibling write routes use — plus whatever narrower owner rule ADR-020 D3 actually intends, since "workspace owner only" is stricter than pod membership. Pin both with a non-member test and a member positive control.
POST /api/activity/:activityId/approveand/rejectcarry no authorization beyondauth. Any authenticated user who knows an activity id can approve or reject anapproval_neededactivity in a pod they are not a member of.Measured at
origin/main9ee1fac6d, not read off the source.Reproduction
Caller is a member of no pod; the activity belongs to
pod-1, whosecreatedByandmembersaresomeone-else.The
/createline is the positive control, run in the same harness against the same mocked pod and caller. It refuses and never reaches the model — so the harness can see a guard, andapprove's 200 is the route's behaviour rather than a bypassed mock.activity.approvewas called with the attacker's own userId, so the row records them as the approver.Where the gap is
routes/activity.tsrequiresisPodMemberand applies it on exactly two routes::231—POST /seed/:podId:258—POST /create:197/:activityId/approveand:211/:activityId/rejectareauth-only. The service behind them does not compensate:ActivityService.approveActivity/rejectActivitydoActivity.findById, checkactivity.type !== 'approval_needed', and callactivity.approve(userId, notes). Neither loads the pod.__tests__/unit/routes/activity.write-membership.test.jscovers/createand/seed/:podIdonly — the file's own header names the create hole as the one that mattered, and the approve/reject pair was not in that sweep.Why it matters
This is the resolve verb for ADR-020 cards, whose D3 states cards are actionable by the workspace owner only, and TASK-095 v1.1 is currently being specced with
resolveApprovalas the single resolve verb for clickable options. A ruling surface that anyone can press is worth closing before more UI points at it.Not caused by, but adjacent to, #1451
#1451 is a correct fix to a separate dead-predicate bug in
getPendingApprovalsand should not be held for this. It does widen which users see pending approvals (creator-only → creator + members), which surfaces more activity ids to more people; those people are legitimate pod members, so it is exposure rather than escalation. The hole here is independent of it and predates it.Suggested fix
Load the activity's pod and apply the same
isPodMembercheck the sibling write routes use — plus whatever narrower owner rule ADR-020 D3 actually intends, since "workspace owner only" is stricter than pod membership. Pin both with a non-member test and a member positive control.