fix(activity): gate both pod-scoped write routes on membership (#1300) - #1302
fix(activity): gate both pod-scoped write routes on membership (#1300)#1302lilyshen0722 wants to merge 2 commits into
Conversation
POST /api/activity/create was `auth`-only: no membership check, no pod-existence check, with `type` and `podId` taken straight off the body. It mattered beyond an ordinary missing gate because the caller does not need to know the approval schema exists — `Activity.approval.status` declares `default: 'pending'`, so Mongoose materialises exactly the two fields `Activity.getPendingApprovals` filters on. A row of `type: 'approval_needed'` therefore lands in an arbitrary pod's ADMINS' decision queue with attacker-controlled content. POST /api/activity/seed/:podId is the same hole by another door, and is the DESIGNED producer of approval_needed rows: it checked that the pod and the user exist and wrote four rows into any pod. Both now resolve the pod and refuse a non-member. `/create` additionally refuses `approval_needed` outright — it is the generic client-facing create, and the approval kind is what fills a decision queue. The membership predicate moves to backend/utils/isPodMember.ts rather than being copied: podInvites.ts already had this exact function and now imports it, so there is one definition of who may write into a pod. It deliberately omits the admin bypass DMService.canViewPod carries — that bypass exists for read observability. Every test asserts the write did not happen, not just the status code. Mutation table, exclusion arm on each row (86 suites / 523 tests): /create membership deleted 1 red / 0 without approval_needed guard deleted 1 red / 0 without /seed membership deleted 1 red / 0 without isPodMember always true 4 red (2 of them podInvites' own) Nothing else in the repo catches any of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeQL flagged the pod lookup I added: js/sql-injection, high, at the new Pod.findById in /create. It is right. `podId` arrives as `unknown` off the body, so a raw object reaches the query as Mongo operators rather than as an id. Coerced with String() on both routes — /seed takes its id from params, where it is always a string, but the two lookups should not differ on a security-relevant detail. The created row now stores `pod._id` — the pod actually resolved and authorised — rather than the body's copy of it. Two cases added. The coercion one asserts on the ARGUMENT handed to findById, not on the response status: what a mocked findById returns for a malformed id is a property of the mock, while what the route passes it is the thing under test. Pod fixtures gained the `_id` they should always have had. Mutations, both 1 red / 12 green: String() reverted on /create podId: pod._id reverted to the body's podId Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed at 1. The threat description overstates the reach, and the reason is a dead guard
{ $or: [ { createdBy: userId }, { 'members.userId': userId, 'members.role': 'admin' } ] }
The control matters: M really is in the array, so it is the query shape that fails, not the fixture. So the injected row reaches the pod creator's queue and no one else's, and the admin-members branch of that queue is dead for every pod on main. That does not weaken the fix — the injection was real and creator-reach is plenty — but the PR body's framing is what a reader will carry forward. It is also worth its own issue: the approvals surface is narrower than its own query claims, which is the sort of thing that gets "fixed" later by someone widening 2. The mutation table's anchor count does not match the fileThe body says "10 cases" and I reproduced the first row rather than take it: deleting 3.
|
|
@sprint-review — right, and the anchor was stale in two places, not one. Corrected in the body; delta so you don't have to re-read it:
Your row-1 reproduction matches mine exactly (1 red / 13, siblings green). Body edit only, no head move — that draws |
Closes #1300.
What was open
POST /api/activity/createwasauth-only — no membership check, no pod-existence check, withtypeandpodIdtaken straight off the body.It matters beyond an ordinary missing gate because the caller does not need to know the approval schema exists.
models/Activity.tsdeclaresapproval.statuswithdefault: 'pending', so Mongoose materialises exactly the two fieldsActivity.getPendingApprovalsfilters on. A body of{ type: 'approval_needed', action, podId }therefore lands in an arbitrary pod's admins' decision queue with attacker-controlled content, and the request never mentionsapprovalat all.POST /api/activity/seed/:podIdis the same hole by another door, and it is the designed producer ofapproval_neededrows: it checked that the pod exists and the user exists, then wrote four rows into any pod for any authenticated caller.What this changes
Both routes resolve the pod and refuse a non-member (404 for an unknown pod, 403 for a non-member).
/createadditionally refusesapproval_neededoutright — it is the generic client-facing create, and the approval kind is what fills a decision queue.The membership predicate moves to
backend/utils/isPodMember.tsrather than being copied.routes/podInvites.tsalready had this exact function and now imports it, so there is one definition of who may write into a pod. It deliberately omits the admin bypassDMService.canViewPodcarries — that bypass exists for read observability, and it would make "only members can write here" untrue for the account most able to do damage by accident.Proof
Every case asserts the write did not happen, not just the status code —
Activity.create/seedPodActivitiesare checked for non-invocation. 13 cases inactivity.write-membership.test.js, including the creator-not-in-memberscase, a populated-subdocument member, a positive control that the same member may create an ordinary kind, and three cases pinning that both routes handPod.findByIda string rather than the body's copy.Mutation table, exclusion arm on every row (86 suites / 523 tests with my file removed):
/createmembership check deletedapproval_neededguard deleted/seedmembership check deletedisPodMemberalways returns truepodInvites' own)Nothing else in the repo catches any of the first three. The fourth is the check that the extraction did not weaken
podInvites.Re-run at
51f7a13c:Tests: 13 totalon each mutation run, so each one compiled. The earlier revision of this table was anchored at 10, before the three injection cases were added in51f7a13c; the four rows are unchanged in substance. Row 4's named reds arerefuses a non-member and writes nothing+refuses a non-member and never reaches the seeder(mine) andrefuses to list invites for a non-member+refuses to revoke an invite for a non-member(podInvites'). Backend typecheck is the usual ~50 pre-existing errors and none name these files; lint on the new test file is 3 errors of the knownimport/no-unresolved+import/extensionsclass every.jstest importing a.tsmodule inherits (the siblingactivity.identity.test.jscarries 6).Not done here
The
approval_neededkind now has no reachable producer outside the seeder, which is the finding ADR-017's fact-source section records (#1256) — this PR closes the injection, it does not build the real producer.🤖 Generated with Claude Code