fix(activity): gate both pod-scoped write routes on membership (#1300) - #1302
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 |
…claim Two corrections from sprint-review's gate, both verified here rather than accepted: - /pulls/:n/comments (inline review comments) is a third collection and does carry commit_id. The rule stands — every inline comment's pull_request_review_id resolves to an event /pulls/:n/reviews returns (#1312, #1302, #1260) — but the entry's surface count was wrong, in an entry about getting a surface count wrong. Also: they are not rare here; a repo-wide sweep finds them on #1312/#1302/#1297/#1274/#1260/#1176/#1094/#1022. The 0-across-five-PRs sample was all docs rows. - The entry claimed the comments collection is "what gh pr view N prints without flags". False. Bare gh pr view prints neither. --comments prints BOTH interleaved, split only by a status: line and with no sha on either; --json comments returns half. On #1338: 2 vs 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a commit (#1338) * docs(ax): entry 51 — a PR's two comment surfaces, and the one without a commit_id `gh pr view --json comments` and `/pulls/:n/reviews` are disjoint sets, not a set and a subset: `gh pr review --comment` files a review event that never appears in the comments collection. The comments surface is the default projection and the obvious one to reach for, so an agent asking "has anyone gated the tree that would press?" reads it, sees nothing, and concludes nobody has — which is what produced a false published warning against pressing a ready PR. The sharper half is that an issue comment carries no `commit_id` at all, so that surface cannot answer the question even when it does show a gate. Measured across eight open PRs: one with a live gate a comments read omits, one with a gate at a dead sha, and one correctly gated with zero review events, where the only thing binding the approval to a tree is that the reviewer typed the sha into the prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — third collection, and correct the gh-projection claim Two corrections from sprint-review's gate, both verified here rather than accepted: - /pulls/:n/comments (inline review comments) is a third collection and does carry commit_id. The rule stands — every inline comment's pull_request_review_id resolves to an event /pulls/:n/reviews returns (#1312, #1302, #1260) — but the entry's surface count was wrong, in an entry about getting a surface count wrong. Also: they are not rare here; a repo-wide sweep finds them on #1312/#1302/#1297/#1274/#1260/#1176/#1094/#1022. The 0-across-five-PRs sample was all docs rows. - The entry claimed the comments collection is "what gh pr view N prints without flags". False. Bare gh pr view prints neither. --comments prints BOTH interleaved, split only by a status: line and with no sha on either; --json comments returns half. On #1338: 2 vs 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — the gate check built from it is prefix-width-sensitive The #1330 case forces a prose-sha query; that query has a free width parameter. This repo writes 8-char shas, so a 9-char prefix returns zero across all 12 open PRs measured — indistinguishable from an arm that never ran. At 8 it finds a gate at head on 9 of 12. Prescribe 7 (git's minimum abbreviation) plus a positive control for any arm that returns an all-population zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax): entry 51 — delete the prefix width, don't retune it sprint-review's review of 4ce6e8a is right twice. "This repo writes 8" is a majority habit, not a rule — #1322 and a #1325 comment write 9 (re-derived, not borrowed). And "cut to 7 so it catches any convention shorter than 8" is self-refuting: grep 'a1607e8' does not match a1607e, so 7 relocates the threshold and tells the next reader the check is safe. Replace the width with a width-free comparison: extract hex tokens from the body and test whether the head STARTS WITH the token. Verified on the same population (a1607e8 on #1330, 35e4a1a on #1327). The residual minimum-token-length knob fails by over-reporting, which is visible, rather than to zero, which reads as an answer. Promote the positive control above the width advice — it is what catches the class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…on by hand Four conflicts, one of them the dangerous kind (sprint-review gate): - routes/integrations.ts POST: deliberate UNION of main's #1311 first-run default (mirror + liveRelay on) and this PR's four create-path gates. Ordering ruled: defaulting runs BEFORE the liveRelay===true stamp, so a defaulted-on connector is bound to its creator (a live relay with no linkedUserId authors nothing inbound and still streams outbound). Test pins it. - Membership gate takes #1302's isPodMember (write predicate, no admin read-bypass) instead of DMService.canViewPod, per the review. - #1293 closed at both sites: liveRelay/relayAllAgentMessages arriving as the strings 'true'/'false' are coerced at the edge, so a string can no longer skip the stamp (PATCH impersonation vector) or the group refusal. Two tests. - linkedUserId test file: both describe blocks kept; first-run tests get the pod-membership mock the new gate needs. - V2ConnectorsPage: #1304 rewrote the card; the expired-code "New code" affordance is re-applied onto the new code-step block, and the pending poll now keys on a LIVE code, not merely a present one. - telegramConnectCode.ts names the autoscaling.backend coupling behind its one-replica premise (gate note a). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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