Skip to content

fix(activity): gate both pod-scoped write routes on membership (#1300) - #1302

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/activity-write-membership
Aug 30, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
fix/activity-write-membership

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #1300.

What was open

POST /api/activity/create was auth-only — no membership check, no pod-existence check, with type and podId taken 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.ts declares approval.status with default: 'pending', so Mongoose materialises exactly the two fields Activity.getPendingApprovals filters 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 mentions approval at all.

POST /api/activity/seed/:podId is the same hole by another door, and it is the designed producer of approval_needed rows: 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). /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. routes/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, 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 / seedPodActivities are checked for non-invocation. 13 cases in activity.write-membership.test.js, including the creator-not-in-members case, a populated-subdocument member, a positive control that the same member may create an ordinary kind, and three cases pinning that both routes hand Pod.findById a string rather than the body's copy.

Mutation table, exclusion arm on every row (86 suites / 523 tests with my file removed):

Mutation red with my file red without
/create membership check deleted 1 0
approval_needed guard deleted 1 0
/seed membership check deleted 1 0
isPodMember always returns true 2 2 (podInvites' 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 total on each mutation run, so each one compiled. The earlier revision of this table was anchored at 10, before the three injection cases were added in 51f7a13c; the four rows are unchanged in substance. Row 4's named reds are refuses a non-member and writes nothing + refuses a non-member and never reaches the seeder (mine) and refuses 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 known import/no-unresolved + import/extensions class every .js test importing a .ts module inherits (the sibling activity.identity.test.js carries 6).

Not done here

The approval_needed kind 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

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>
Comment thread backend/routes/activity.ts Fixed
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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Reviewed at 51f7a13c. The fix is right and the gate is in the correct place — the write predicate omitting canViewPod's admin bypass is exactly the distinction I raised on #1297, and it is good to see it land as one definition rather than a third copy. Three things, one of which is a measurement rather than a nit.

1. The threat description overstates the reach, and the reason is a dead guard

lands in an arbitrary pod's admins' decision queue

getPendingApprovals selects pods with:

{ $or: [ { createdBy: userId }, { 'members.userId': userId, 'members.role': 'admin' } ] }

Pod.members is [{ type: Schema.Types.ObjectId, ref: 'User' }] (models/Pod.ts:138) — bare ObjectIds, no userId or role subfields. Measured on a real Mongo with a positive control, one pod, createdBy: C, members: [C, M]:

query matches
{ createdBy: C } branch 1
{ 'members.userId': M, 'members.role': 'admin' } branch 0
control { members: M } 1

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 members and silently switching a dead branch on.

2. The mutation table's anchor count does not match the file

The body says "10 cases" and Tests: 10 total on each mutation run. The file as submitted has 13 it( blocks and runs 13/13. So the table was produced against an earlier revision — and by the discipline the table itself invokes (assert the total so a compile failure cannot masquerade as a pass), that anchor no longer anchors anything. Worth re-running at this head.

I reproduced the first row rather than take it: deleting if (!isPodMember(pod, userId)) from /create gives 1 failed, 12 passed, 13 totalrefuses a non-member and writes nothing. And the exclusion arm holds on that row: with the guard still deleted, activity.read.test.js + activity.identity.test.js (the only other suites touching create/seed) stay 6/6 green, so nothing else catches it.

3. type is a denylist

if (type === 'approval_needed') return 400 is complete todayActivityType is five values and that is the only privileged one. But the next privileged kind inherits the hole silently, and the route's own comment explains why this kind is special in a way that will read as settled. An allowlist of client-creatable kinds fails closed instead.

Smaller

404-for-unknown-pod vs 403-for-non-member is an existence oracle: a non-member learns the pod exists. The convention elsewhere for personal pod types is to 404 non-members (#375/#377/#378/#381). Not blocking, and arguably not worth changing for ordinary pods — noting it so the difference is deliberate.

Verified good

The podInvites extraction is behaviour-preserving — userId.toString()String(userId), both behind the same !userId guard, and the widened unknown parameter type only relaxes the call side. .select('members createdBy') covers exactly the two fields isPodMember reads, so no predicate silently evaluates against an absent projection. String(podId) before findById closes the operator-injection path, and storing pod._id instead of the body's copy removes the resolved-vs-supplied mismatch.

Not verified

The full 86-suite exclusion arm — I checked one row against the two sibling activity suites, not the whole run. The typecheck and lint counts. And podInvites' own behaviour beyond reading the diff.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

@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:

  • "10 cases" → 13. The three added in 51f7a13c pin that both routes hand Pod.findById a string rather than the body's copy (the CodeQL js/sql-injection fix). The table was measured before them.
  • Tests: 10 totalTests: 13 total, re-run at 51f7a13c. All four rows re-measured at this head, not carried forward:
Mutation red with my file red without
/create membership check deleted 1 0
approval_needed guard deleted 1 0
/seed membership check deleted 1 0
isPodMember always returns true 2 2 (podInvites' own)
  • Row 4 was ambiguous, and that is the one worth flagging. It read 4 | — (2 of the 4 are podInvites' own), which puts an exclusion-arm number inside the with-file column. It is 2 and 2: mine are refuses a non-member and writes nothing + refuses a non-member and never reaches the seeder; podInvites' are refuses to list invites for a non-member + refuses to revoke an invite for a non-member. Same four tests you'd get either way — the count was right and the columns were not.

Your row-1 reproduction matches mine exactly (1 red / 13, siblings green).

Body edit only, no head move — that draws PR Base Freshness and Package Version Guard on the edited trigger and nothing else.

samxu01 pushed a commit that referenced this pull request Aug 29, 2026
…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>
lilyshen0722 added a commit that referenced this pull request Aug 30, 2026
… 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>
@lilyshen0722
lilyshen0722 merged commit 97a9033 into main Aug 30, 2026
13 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/activity-write-membership branch August 30, 2026 01:07
samxu01 pushed a commit that referenced this pull request Sep 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

POST /api/activity/create lets any authenticated user inject a row into any pod's approval queue

2 participants