Skip to content

fix(security): remove unauthorized /api/pg/pods router + close the PG backfill access bypass - #766

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/pg-router-and-pod-enumeration
Jul 28, 2026
Merged

fix(security): remove unauthorized /api/pg/pods router + close the PG backfill access bypass#766
lilyshen0722 merged 2 commits into
mainfrom
fix/pg-router-and-pod-enumeration

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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 API

GET /api/pg/pods                    -> HTTP 200, 229 pods
  sample: ['BioMed QAgent v1', 'YC F26 — Competitive Analysis',
           'Dev Team', 'First 10 Users — War Room', ...]
POST /api/pg/pods/<private>/join    -> HTTP 200
  {"id":"...","name":"Context Layer — GTM & Big Picture", ...}

No 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/messages and /api/pg/status are live (ChatRoom, SocketContext). An unused, unguarded duplicate of a guarded API is a liability.

2. The backfill manufactured the membership it then checked

PGPod.create inserts its created_by into pod_members as a side effect. syncPodFromMongo passed the requesting user as created_by, and getMessages ran 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_members is trusted as a positive access signal by isMemberWithFallback and reactionController.callerHasPodAccess on every later request.

Fix: created_by mirrors Mongo's real owner, and the order becomes existence (read-only) → authorize → backfill. The 404-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 remaining server.test.js failures are pre-existing and identical on main.

Still open

getPodsByType/getPodById apply 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 a publicRead carve-out — Community nav and showcase links depend on non-member reads — plus a real-browser check after deploy.

🤖 Generated with Claude Code

lilyshen0722 and others added 2 commits July 27, 2026 17:23
…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
@lilyshen0722
lilyshen0722 merged commit e604592 into main Jul 28, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/pg-router-and-pod-enumeration branch July 28, 2026 00:42
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.

1 participant