Skip to content

fix(credential-sets): security hardening#5074

Merged
waleedlatif1 merged 1 commit into
stagingfrom
fix/credential-set-invite-token-leak
Jun 15, 2026
Merged

fix(credential-sets): security hardening#5074
waleedlatif1 merged 1 commit into
stagingfrom
fix/credential-set-invite-token-leak

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • 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 those tokens and accept another org's invitation via POST /api/credential-sets/invite/[token] (which skips the email check when email IS NULL), joining that org's credential set — cross-tenant broken access control.
  • Root cause: the isNull(email) branch in the listing's WHERE clause had no organization scoping, broadcasting open-invite tokens to everyone.
  • Fix: scope the listing strictly to invitations addressed to the caller's own email. Open-link invites stay redeemable only via the out-of-band /credential-account/[token] URL, where possession of the unguessable token is the intended (and now non-enumerable) secret.

Type of Change

  • Bug fix (security — cross-tenant privilege escalation)

Testing

Tested manually. bun run check:api-validation passes; 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

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

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.
@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 15, 2026 11:14pm

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor

cursor Bot commented Jun 15, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Closes a security flaw (cross-tenant privilege escalation via enumerable invite tokens); behavior change is intentional and narrows exposure rather than widening it.

Overview
Fixes a cross-tenant authorization bug on GET /api/credential-sets/invitations: the handler previously included pending link-only invites (email IS NULL) for every org and returned their bearer token, so any signed-in user could enumerate tokens and accept another org’s invite.

The listing is now limited to invitations whose email matches the session user. Open-link invites are no longer exposed in this API; they remain redeemable only via the out-of-band invite URL where the token is the secret.

Reviewed by Cursor Bugbot for commit e93a5ca. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@waleedlatif1 waleedlatif1 changed the title fix(credential-sets): stop leaking open-invite tokens to all users fix(credential-sets): security hardening Jun 15, 2026
@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a cross-tenant broken access control bug in GET /api/credential-sets/invitations. The old WHERE clause included an OR isNull(email) branch that returned every open-link (null-email) invitation — including its bearer token — to any authenticated user, enabling cross-org credential set join via POST /api/credential-sets/invite/[token].

  • Root cause removed: the isNull(email) / or branch is dropped; the listing is now scoped to eq(email, session.user.email) only.
  • Open-link invites: remain redeemable via the out-of-band /credential-account/[token] URL (possession of the unguessable token is the access control); they are simply no longer enumerable via the listing API.
  • Accept path unchanged: POST /api/credential-sets/invite/[token] already enforces email-match for addressed invites and accepts null-email invites on token possession alone — no changes needed there.

Confidence Score: 5/5

Safe to merge — the one-line predicate change closes the enumeration path cleanly without touching any other flow.

The change is a minimal, surgical WHERE-clause narrowing in a single file. The accept endpoint was already correct (email-match enforced for addressed invites, token possession required for open-link invites), so no other paths need adjustment. The fix is easy to reason about end-to-end and the PR description accurately describes the before/after behavior.

No files require special attention. apps/sim/app/api/credential-sets/invite/[token]/route.ts was reviewed for completeness and its accept logic is correct and unchanged.

Important Files Changed

Filename Overview
apps/sim/app/api/credential-sets/invitations/route.ts Removes or(eq(email, session.user.email), isNull(email)) and replaces it with the tightly-scoped eq(email, session.user.email), closing the cross-tenant token-leak. Change is minimal, correct, and well-commented.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as Authenticated User
    participant L as GET /api/credential-sets/invitations
    participant A as POST /api/credential-sets/invite/[token]
    participant DB as Database

    note over L,DB: BEFORE (vulnerable)
    U->>L: GET invitations
    L->>DB: "WHERE email = me OR email IS NULL"
    DB-->>L: All pending invitations (any org, including tokens)
    L-->>U: Tokens for other orgs open-link invites
    U->>A: "POST /invite/{stolen_token}"
    A->>DB: Lookup token (no email check for null-email invites)
    DB-->>A: Invitation found
    A-->>U: Joined another org credential set

    note over L,DB: AFTER (fixed)
    U->>L: GET invitations
    L->>DB: "WHERE email = me (only)"
    DB-->>L: Only invitations addressed to caller
    L-->>U: Callers own tokens only
    U->>A: "POST /invite/{own_token}"
    A->>DB: Lookup + enforce email match
    A-->>U: Accept own invitation only
Loading
%%{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 L as GET /api/credential-sets/invitations
    participant A as POST /api/credential-sets/invite/[token]
    participant DB as Database

    note over L,DB: BEFORE (vulnerable)
    U->>L: GET invitations
    L->>DB: "WHERE email = me OR email IS NULL"
    DB-->>L: All pending invitations (any org, including tokens)
    L-->>U: Tokens for other orgs open-link invites
    U->>A: "POST /invite/{stolen_token}"
    A->>DB: Lookup token (no email check for null-email invites)
    DB-->>A: Invitation found
    A-->>U: Joined another org credential set

    note over L,DB: AFTER (fixed)
    U->>L: GET invitations
    L->>DB: "WHERE email = me (only)"
    DB-->>L: Only invitations addressed to caller
    L-->>U: Callers own tokens only
    U->>A: "POST /invite/{own_token}"
    A->>DB: Lookup + enforce email match
    A-->>U: Accept own invitation only
Loading

Reviews (1): Last reviewed commit: "fix(credential-sets): stop leaking open-..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a cross-tenant information disclosure bug where GET /api/credential-sets/invitations was returning open-invite (null-email) pending invitations — including their bearer token — to every authenticated user regardless of organization. The single-line fix removes the isNull(email) branch from the WHERE clause, scoping the listing strictly to invitations addressed to the caller's own email address.

  • Removes or(eq(...email, session.user.email), isNull(...email)) and replaces it with eq(...email, session.user.email) so only the caller's own email-addressed invitations are enumerated.
  • Open-link (null-email) invitations remain redeemable via the out-of-band /credential-account/[token] URL; possession of the unguessable token is the intended access control mechanism, and that token is now non-enumerable.

Confidence Score: 5/5

Safe 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

Filename Overview
apps/sim/app/api/credential-sets/invitations/route.ts Removes the isNull(email) branch from the invitations listing WHERE clause, preventing cross-tenant open-invite token enumeration. The fix is minimal, targeted, and correct.

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
Loading
%%{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
Loading

Reviews (2): Last reviewed commit: "fix(credential-sets): stop leaking open-..." | Re-trigger Greptile

@waleedlatif1 waleedlatif1 merged commit 3e2b641 into staging Jun 15, 2026
15 checks passed
@waleedlatif1 waleedlatif1 deleted the fix/credential-set-invite-token-leak branch June 15, 2026 23:24
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