fix(credential-sets): security hardening#5074
Conversation
GET /api/credential-sets/invitations returned every pending, unexpired link-only (null-email) invitation across all organizations, including the bearer token. Any authenticated user could enumerate and accept another org's invitation, joining its credential set (cross-tenant access). Scope the listing strictly to invitations addressed to the caller's own email. Open-link invites remain redeemable only via the out-of-band /credential-account/[token] URL.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@greptile |
|
@cursor review |
PR SummaryHigh Risk Overview The listing is now limited to invitations whose Reviewed by Cursor Bugbot for commit e93a5ca. Configure here. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e93a5ca. Configure here.
Greptile SummaryThis PR fixes a cross-tenant information disclosure bug where
Confidence Score: 5/5Safe to merge — the change is a single targeted WHERE clause narrowing that closes an enumeration hole without touching the acceptance path or any other functionality. The diff removes exactly the isNull(email) branch responsible for the cross-tenant leak. The acceptance endpoint email enforcement and the open-link token-possession model are both unchanged and correct. The early-return auth guard on session email prevents a null session email from accidentally widening the query. No unintended side-effects are introduced. No files require special attention. The acceptance route at apps/sim/app/api/credential-sets/invite/[token]/route.ts was reviewed as context and is unaffected by this PR. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant U as Authenticated User
participant LIST as GET /api/credential-sets/invitations
participant ACCEPT as POST /api/credential-sets/invite/[token]
participant DB as Database
Note over LIST,DB: BEFORE: returned ALL pending null-email invitations (any org)
U->>LIST: GET /invitations
LIST->>DB: "WHERE (email = me OR email IS NULL) AND status=pending"
DB-->>LIST: rows including open-invite tokens from other orgs
LIST-->>U: tokens for other orgs open invites exposed
Note over LIST,DB: AFTER: only caller own email-addressed invitations
U->>LIST: GET /invitations
LIST->>DB: "WHERE email = session.email AND status=pending"
DB-->>LIST: only invitations addressed to this user
LIST-->>U: callers own invitations token safe to return
Note over ACCEPT,DB: Open-link invite redemption unchanged by design
U->>ACCEPT: POST /invite/[token] token from out-of-band URL
ACCEPT->>DB: "SELECT WHERE token = :token"
DB-->>ACCEPT: "invitation email=null"
Note over ACCEPT: email IS NULL skip email check token possession = auth
ACCEPT->>DB: INSERT member + UPDATE invitation accepted
ACCEPT-->>U: accepted
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant U as Authenticated User
participant LIST as GET /api/credential-sets/invitations
participant ACCEPT as POST /api/credential-sets/invite/[token]
participant DB as Database
Note over LIST,DB: BEFORE: returned ALL pending null-email invitations (any org)
U->>LIST: GET /invitations
LIST->>DB: "WHERE (email = me OR email IS NULL) AND status=pending"
DB-->>LIST: rows including open-invite tokens from other orgs
LIST-->>U: tokens for other orgs open invites exposed
Note over LIST,DB: AFTER: only caller own email-addressed invitations
U->>LIST: GET /invitations
LIST->>DB: "WHERE email = session.email AND status=pending"
DB-->>LIST: only invitations addressed to this user
LIST-->>U: callers own invitations token safe to return
Note over ACCEPT,DB: Open-link invite redemption unchanged by design
U->>ACCEPT: POST /invite/[token] token from out-of-band URL
ACCEPT->>DB: "SELECT WHERE token = :token"
DB-->>ACCEPT: "invitation email=null"
Note over ACCEPT: email IS NULL skip email check token possession = auth
ACCEPT->>DB: INSERT member + UPDATE invitation accepted
ACCEPT-->>U: accepted
Reviews (2): Last reviewed commit: "fix(credential-sets): stop leaking open-..." | Re-trigger Greptile |
Summary
GET /api/credential-sets/invitationsreturned every pending, unexpired link-only (null-email) invitation across all organizations, including the bearertoken. Any authenticated user could enumerate those tokens and accept another org's invitation viaPOST /api/credential-sets/invite/[token](which skips the email check whenemail IS NULL), joining that org's credential set — cross-tenant broken access control.isNull(email)branch in the listing'sWHEREclause had no organization scoping, broadcasting open-invite tokens to everyone./credential-account/[token]URL, where possession of the unguessable token is the intended (and now non-enumerable) secret.Type of Change
Testing
Tested manually.
bun run check:api-validationpasses; changed file typechecks clean. In-app "Pending Invitations" still lists email-addressed invites (token returned is the caller's own, and accept still enforces the email match).Checklist