fix(security): remove unauthorized /api/pg/pods router + close the PG backfill access bypass - #766
Merged
Merged
Conversation
…the backfill access bypass Two confirmed-live holes, both reachable by any plain authenticated account. 1. GET /api/pg/pods returned every pod on the instance — 229 of them, private ones included, with names like "YC F26 — Competitive Analysis" — with no membership filter. POST /api/pg/pods/:id/join then let a non-member join a private pod and returned 200 with the pod body. Verified by execution. This shadow router duplicated the pod API without its authorization. It had ZERO callers anywhere — frontend v1 and v2, cli, mcp, examples, e2e, scripts all use /api/pods; only /api/pg/messages and /api/pg/status are live (ChatRoom, SocketContext). Removed rather than patched: an unused, unguarded duplicate of a guarded API is a liability, not a feature. 2. The PG backfill let a non-member grant themselves membership. PGPod.create inserts its created_by argument into pod_members as a side effect, syncPodFromMongo passed the REQUESTING user as created_by, and pgMessageController.getMessages ran that backfill BEFORE its membership check — so a non-member asking for a pod not yet mirrored into PG caused the server to insert them, and the very next line read that row back and let them in. The manufactured row persists and is trusted by isMemberWithFallback and reactionController.callerHasPodAccess on every later request. created_by now mirrors Mongo's real owner, and getMessages resolves existence (read-only), then authorizes, then backfills. The 404-for-missing / 401-for-non-member contract is preserved exactly. Also removes the tests for the deleted router and its stale mock in server.test.js. Net test delta vs origin/main: zero regressions. The 9 remaining server.test.js failures are pre-existing and identical on main. Still open, code-confirmed but not executed: podController.getPodsByType and getPodById apply their membership filter only to the three personal pod types, so chat/team pods remain enumerable instance-wide. Filed separately because the fix needs a publicRead carve-out (Community nav and showcase links depend on non-member reads) and a real-browser check after deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES
CodeQL flagged js/missing-rate-limiting on routes/pg-messages.ts once this PR touched the file. The gap was pre-existing — these routes hit the database with no limiter at all, so a leaked token could spray unbounded reads and writes. Mirrors routes/messages.ts, which fronts the same tables: 240 reads/min and 60 writes/min, keyed on the hashed bearer token so users behind one NAT get their own bucket, with the IPv6-safe fallback for the unauth path. Imported as ESM and placed first on each route because the query cannot trace a limiter through a require() return or a router.use wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMeWzgFxsfBcjoLVLewEES
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of the security cluster (first is #765). Both holes here were confirmed by live execution as a plain, non-admin account.
1.
/api/pg/pods— an unauthorized shadow copy of the pod APINo membership filter on the listing; no join-policy check at all on the join. It duplicated the pod API without its authorization.
Removed, not patched. It has zero callers anywhere in the repo — frontend v1+v2, cli, mcp, examples, e2e and scripts all use
/api/pods; only/api/pg/messagesand/api/pg/statusare live (ChatRoom, SocketContext). An unused, unguarded duplicate of a guarded API is a liability.2. The backfill manufactured the membership it then checked
PGPod.createinserts itscreated_byintopod_membersas a side effect.syncPodFromMongopassed the requesting user ascreated_by, andgetMessagesran the backfill before its membership check — so a non-member requesting a pod not yet mirrored into PG caused the server to insert them, and the next line read that row back and let them through.Worse than a single-request bypass: the row persists, and PG
pod_membersis trusted as a positive access signal byisMemberWithFallbackandreactionController.callerHasPodAccesson every later request.Fix:
created_bymirrors Mongo's real owner, and the order becomes existence (read-only) → authorize → backfill. The404-for-missing /401-for-non-member contract is preserved exactly — I initially collapsed it to 401 and an existing test caught it.Test
4 new tests pinning attribution and that the requester is never added to the member list. Proven load-bearing: reverting the one-line attribution fix fails 2 of them.
Net test delta vs
origin/main: zero regressions. The 9 remainingserver.test.jsfailures are pre-existing and identical on main.Still open
getPodsByType/getPodByIdapply their membership filter only to the three personal pod types, so chat/team pods stay enumerable instance-wide. Code-confirmed, not executed. Filed separately because the fix needs apublicReadcarve-out — Community nav and showcase links depend on non-member reads — plus a real-browser check after deploy.🤖 Generated with Claude Code