Skip to content

fix: scope recent-achievements query by userId (cross-tenant data leak)#410

Merged
edspencer merged 1 commit into
mainfrom
fix/dashboard-cross-tenant-achievement-leak
Jul 7, 2026
Merged

fix: scope recent-achievements query by userId (cross-tenant data leak)#410
edspencer merged 1 commit into
mainfrom
fix/dashboard-cross-tenant-achievement-leak

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Security fix — CRITICAL

The dashboard recent-achievements endpoint leaks every user's achievements to any authenticated user.

Root cause

apps/web/app/api/dashboard/recent-achievements/route.ts combined its filters with JavaScript && instead of Drizzle's and():

.where(eq(achievement.userId, userId) && eq(achievement.isArchived, false))

eq() returns a truthy query-fragment object, so truthy && expr evaluates to only the right-hand operand. The effective query was WHERE isArchived = false — no user scoping. The endpoint returned the 10 most-recent achievements across all users (title, summary, impact, project, company) on every dashboard load.

The if (!userId || userId !== user.id) guard above only validates the query-string parameter; the DB query ignored it entirely, so the check gave a false sense of safety.

Fix

Use and() so both predicates apply:

.where(and(eq(achievement.userId, userId), eq(achievement.isArchived, false)))

Scope check

Grepped the codebase for the same bug class (&& between Drizzle operators inside a .where()) — this was the only occurrence.

Notes

  • No preconditions to exploit; fires on normal dashboard use.
  • Found during a full security review; see SECURITY-REVIEW.md (finding CRIT-2). Consider a lint rule to ban && inside Drizzle .where(...).

🤖 Generated with Claude Code

The dashboard recent-achievements query used JavaScript `&&` instead of
Drizzle's `and()` to combine its filters:

    .where(eq(achievement.userId, userId) && eq(achievement.isArchived, false))

`eq()` returns a truthy query-fragment object, so `truthy && expr`
evaluates to only the right-hand operand. The effective query was
`WHERE isArchived = false` with no user scoping, so the endpoint
returned the 10 most-recent achievements across ALL users — title,
summary, impact, project and company — on every dashboard load. The
`userId !== user.id` guard above only validates the query-string param;
the DB query ignored it.

Replace `&&` with `and()` so both predicates are applied. Grepped the
codebase for the same bug class (`&&` between Drizzle operators in a
`.where()`); this was the only occurrence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
bragdoc-ai Ready Ready Preview, Comment Jul 7, 2026 5:59pm

Request Review

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Braintrust eval report

No experiments to report

@edspencer edspencer merged commit fbac5e1 into main Jul 7, 2026
11 checks passed
@edspencer edspencer deleted the fix/dashboard-cross-tenant-achievement-leak branch July 7, 2026 18:17
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