fix(security): enforce project membership across tRPC procedures (IDOR) - #3127
fix(security): enforce project membership across tRPC procedures (IDOR)#3127Mohak-Agrawal wants to merge 1 commit into
Conversation
Closes onlook-dev#3122. The Drizzle ORM client connects via a Postgres superuser role that is exempt from Supabase RLS, so authorization must be enforced in tRPC procedure code. A verifyProjectAccess() helper already existed and was used correctly in some procedures (project.update, project.delete), but was missing from the large majority -- any authenticated user could read, modify, or delete data belonging to other users' projects by supplying an arbitrary projectId/conversationId/branchId/etc. Adds resolve-then-verify helpers to project/helper.ts for resources that don't carry a projectId directly (verifyConversationAccess, verifyMessagesAccess, verifyBranchAccess, verifyCanvasAccess, verifyFrameAccess, verifyInvitationAccess), each following the existing verifyProjectAccess pattern: a merged "Unauthorized or not found" error so the check can't be used to enumerate resource existence. Wires these into every procedure that reads or writes project-scoped data and was missing a check: - project.ts: get, getProjectWithCanvas, getPreviewProjects (now pins to the caller's own session id instead of a client-supplied userId) - fork.ts: fork -- clones a source project's full contents (canvas, branches, live sandboxes); not in the original report but the same class of bug, found while auditing this router - member.ts: list, remove - invitation.ts: list, delete, create (create also unprotected, not in the original report) - chat/conversation.ts: getAll, get, upsert, update, delete - chat/message.ts: getAll, upsert, upsertMany, update, updateCheckpoints, delete, replaceConversationMessages (also forces the inserted conversationId server-side so a caller authorized for conversation A can't smuggle messages into conversation B via the payload array) - branch.ts: getByProjectId, create, update, delete, fork, createBlank - settings.ts: get, upsert, delete - frame.ts: get, getByCanvas, create, update, delete - createRequest.ts: getPendingRequest, updateStatus 25 procedures across 9 files; the original report named 15 across 5. Not covered here: project/sandbox.ts operates on raw CodeSandbox provider ids (start/hibernate/delete/fork), not projectId-keyed DB records, and needs a different resolution path (sandboxId -> branches.sandboxId -> projectId). Flagging as a likely follow-up rather than folding it into this diff. Verified with `tsc --noEmit` per file (bun install wasn't run given the monorepo's size and this being a security-fix diff, not a build check); every flagged type error traced back to unresolvable @onlook/db imports in that isolated environment, confirmed against an equivalent pattern with real types compiling clean under --strict. Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
@Mohak-Agrawal is attempting to deploy a commit to the Onlook Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughProject-, conversation-, message-, branch-, canvas-, frame-, invitation-, and member-scoped procedures now perform explicit authorization checks through shared helpers. Conversation replacement also binds inserted messages to the requested conversation, and preview-project access validates the authenticated user ID. ChangesAuthorization enforcement
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Router
participant AccessHelper
participant Database
Client->>Router: invoke protected procedure
Router->>AccessHelper: verify scoped access
AccessHelper->>Database: query entity and project membership
AccessHelper-->>Router: allow or throw unauthorized
Router->>Database: perform authorized operation
Database-->>Client: return procedure result
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/client/src/server/api/routers/chat/message.ts (1)
27-46: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winScope the upsert to the existing conversation
conversationIdis required, but the conflict path still trusts the client-suppliedid, so a caller who knows another message ID can overwrite that row and reassign it to the request conversation. Add a same-conversation check beforeonConflictDoUpdate, or avoid conflict-based updates here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/client/src/server/api/routers/chat/message.ts` around lines 27 - 46, The upsert mutation currently allows a client-supplied message ID to update a message from another conversation. In the upsert procedure, validate that any existing message identified by normalizedMessage.id belongs to the requested conversationId before onConflictDoUpdate, rejecting mismatches; preserve inserts and same-conversation updates.apps/web/client/src/server/api/routers/project/invitation.ts (1)
24-51: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winAdd
verifyProjectAccesstosuggested
suggestedaccepts aprojectIdbut never checks access, so any authenticated user can enumerate email addresses for arbitrary projects.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/client/src/server/api/routers/project/invitation.ts` around lines 24 - 51, Add verifyProjectAccess to the suggested procedure before querying or returning invitation email data, using its supplied projectId and request context. Preserve the existing suggested behavior for authorized users while rejecting access to projects the authenticated user cannot access.
🧹 Nitpick comments (1)
apps/web/client/src/server/api/routers/chat/conversation.ts (1)
14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse path aliases instead of relative imports for helper module.
Both new imports use
'../project/helper'instead of the@/*or~/*path aliases required by the coding guidelines for files underapps/web/client/src/**.
apps/web/client/src/server/api/routers/chat/conversation.ts#L14-L14: Changefrom '../project/helper'tofrom '@/server/api/routers/project/helper'(or~/...).apps/web/client/src/server/api/routers/chat/message.ts#L12-L12: Same change.As per coding guidelines: "Use path aliases
@/* and ~/* for imports that map to apps/web/client/src/*".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/client/src/server/api/routers/chat/conversation.ts` at line 14, Replace the relative helper imports in apps/web/client/src/server/api/routers/chat/conversation.ts:14 and apps/web/client/src/server/api/routers/chat/message.ts:12 with the `@/`* or ~/* alias targeting the project helper module, preserving the existing verifyConversationAccess and verifyProjectAccess imports.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/client/src/server/api/routers/project/branch.ts`:
- Line 50: Move the authorization checks outside the broad try-catch blocks so
authorization errors propagate to clients instead of being converted to false.
In apps/web/client/src/server/api/routers/project/branch.ts lines 50, 60, and
81, update create to call verifyProjectAccess before its try-catch and
update/delete to call verifyBranchAccess before their try-catches; in
apps/web/client/src/server/api/routers/project/frame.ts lines 42, 54, and 75,
likewise move verifyCanvasAccess or verifyFrameAccess before the corresponding
create, update, and delete try-catches.
In `@apps/web/client/src/server/api/routers/project/helper.ts`:
- Around line 95-104: Deduplicate messageIds before the findMany query and
authorization count check so repeated IDs do not cause a false mismatch. Update
the logic around messageIds and rows.length to compare against the unique ID
count while preserving the existing empty-input return and
unauthorized/not-found error behavior.
In `@apps/web/client/src/server/api/routers/project/project.ts`:
- Around line 363-370: Update getPreviewProjects to throw the file’s established
TRPCError type with the appropriate unauthorized code instead of a plain Error,
and use ctx.user.id rather than input.userId in the userProjects query filter.
Preserve the existing identity validation before querying.
---
Outside diff comments:
In `@apps/web/client/src/server/api/routers/chat/message.ts`:
- Around line 27-46: The upsert mutation currently allows a client-supplied
message ID to update a message from another conversation. In the upsert
procedure, validate that any existing message identified by normalizedMessage.id
belongs to the requested conversationId before onConflictDoUpdate, rejecting
mismatches; preserve inserts and same-conversation updates.
In `@apps/web/client/src/server/api/routers/project/invitation.ts`:
- Around line 24-51: Add verifyProjectAccess to the suggested procedure before
querying or returning invitation email data, using its supplied projectId and
request context. Preserve the existing suggested behavior for authorized users
while rejecting access to projects the authenticated user cannot access.
---
Nitpick comments:
In `@apps/web/client/src/server/api/routers/chat/conversation.ts`:
- Line 14: Replace the relative helper imports in
apps/web/client/src/server/api/routers/chat/conversation.ts:14 and
apps/web/client/src/server/api/routers/chat/message.ts:12 with the `@/`* or ~/*
alias targeting the project helper module, preserving the existing
verifyConversationAccess and verifyProjectAccess imports.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 41af549b-dce8-457b-8193-7a70e8d2047c
📒 Files selected for processing (11)
apps/web/client/src/server/api/routers/chat/conversation.tsapps/web/client/src/server/api/routers/chat/message.tsapps/web/client/src/server/api/routers/project/branch.tsapps/web/client/src/server/api/routers/project/createRequest.tsapps/web/client/src/server/api/routers/project/fork.tsapps/web/client/src/server/api/routers/project/frame.tsapps/web/client/src/server/api/routers/project/helper.tsapps/web/client/src/server/api/routers/project/invitation.tsapps/web/client/src/server/api/routers/project/member.tsapps/web/client/src/server/api/routers/project/project.tsapps/web/client/src/server/api/routers/project/settings.ts
| .input(branchInsertSchema) | ||
| .mutation(async ({ ctx, input }) => { | ||
| try { | ||
| await verifyProjectAccess(ctx.db, ctx.user.id, input.projectId); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Authorization errors swallowed by try-catch blocks in branch.ts and frame.ts. Six procedures place the new verify*Access call inside an existing try-catch that catches all errors and returns false. The authorization Error('Unauthorized or not found') is caught, logged to console, and the client receives false — indistinguishable from a generic DB failure. The security goal (preventing the operation) is met, but the client cannot handle authorization failures.
apps/web/client/src/server/api/routers/project/branch.ts#L50-L50: MoveverifyProjectAccessbefore the try-catch increate.apps/web/client/src/server/api/routers/project/branch.ts#L60-L60: MoveverifyBranchAccessbefore the try-catch inupdate.apps/web/client/src/server/api/routers/project/branch.ts#L81-L81: MoveverifyBranchAccessbefore the try-catch indelete.apps/web/client/src/server/api/routers/project/frame.ts#L42-L42: MoveverifyCanvasAccessbefore the try-catch increate.apps/web/client/src/server/api/routers/project/frame.ts#L54-L54: MoveverifyFrameAccessbefore the try-catch inupdate.apps/web/client/src/server/api/routers/project/frame.ts#L75-L75: MoveverifyFrameAccessbefore the try-catch indelete.
📍 Affects 2 files
apps/web/client/src/server/api/routers/project/branch.ts#L50-L50(this comment)apps/web/client/src/server/api/routers/project/branch.ts#L60-L60apps/web/client/src/server/api/routers/project/branch.ts#L81-L81apps/web/client/src/server/api/routers/project/frame.ts#L42-L42apps/web/client/src/server/api/routers/project/frame.ts#L54-L54apps/web/client/src/server/api/routers/project/frame.ts#L75-L75
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/client/src/server/api/routers/project/branch.ts` at line 50, Move
the authorization checks outside the broad try-catch blocks so authorization
errors propagate to clients instead of being converted to false. In
apps/web/client/src/server/api/routers/project/branch.ts lines 50, 60, and 81,
update create to call verifyProjectAccess before its try-catch and update/delete
to call verifyBranchAccess before their try-catches; in
apps/web/client/src/server/api/routers/project/frame.ts lines 42, 54, and 75,
likewise move verifyCanvasAccess or verifyFrameAccess before the corresponding
create, update, and delete try-catches.
| if (messageIds.length === 0) { | ||
| return; | ||
| } | ||
| const rows = await db.query.messages.findMany({ | ||
| where: inArray(messages.id, messageIds), | ||
| with: { conversation: true }, | ||
| }); | ||
| if (rows.length !== messageIds.length) { | ||
| throw new Error('Unauthorized or not found'); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Duplicate messageIds cause false authorization failure.
inArray in SQL returns distinct rows regardless of duplicate values in the input array. If a caller passes ['a', 'a', 'b'], the query returns 2 rows but messageIds.length is 3, so the rows.length !== messageIds.length check throws 'Unauthorized or not found' even though all messages exist and are accessible. Deduplicate before comparing.
🐛 Proposed fix
export async function verifyMessagesAccess(
db: DbOrTx,
userId: string,
messageIds: string[],
): Promise<void> {
- if (messageIds.length === 0) {
+ const uniqueMessageIds = [...new Set(messageIds)];
+ if (uniqueMessageIds.length === 0) {
return;
}
const rows = await db.query.messages.findMany({
- where: inArray(messages.id, messageIds),
+ where: inArray(messages.id, uniqueMessageIds),
with: { conversation: true },
});
- if (rows.length !== messageIds.length) {
+ if (rows.length !== uniqueMessageIds.length) {
throw new Error('Unauthorized or not found');
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (messageIds.length === 0) { | |
| return; | |
| } | |
| const rows = await db.query.messages.findMany({ | |
| where: inArray(messages.id, messageIds), | |
| with: { conversation: true }, | |
| }); | |
| if (rows.length !== messageIds.length) { | |
| throw new Error('Unauthorized or not found'); | |
| } | |
| export async function verifyMessagesAccess( | |
| db: DbOrTx, | |
| userId: string, | |
| messageIds: string[], | |
| ): Promise<void> { | |
| const uniqueMessageIds = [...new Set(messageIds)]; | |
| if (uniqueMessageIds.length === 0) { | |
| return; | |
| } | |
| const rows = await db.query.messages.findMany({ | |
| where: inArray(messages.id, uniqueMessageIds), | |
| with: { conversation: true }, | |
| }); | |
| if (rows.length !== uniqueMessageIds.length) { | |
| throw new Error('Unauthorized or not found'); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/client/src/server/api/routers/project/helper.ts` around lines 95 -
104, Deduplicate messageIds before the findMany query and authorization count
check so repeated IDs do not cause a false mismatch. Update the logic around
messageIds and rows.length to compare against the unique ID count while
preserving the existing empty-input return and unauthorized/not-found error
behavior.
| // A user may only list their own preview projects — the userId is | ||
| // part of the input schema for backwards-compat client shape, but | ||
| // is not trusted; the session's own id is the authorization source. | ||
| if (input.userId !== ctx.user.id) { | ||
| throw new Error('Unauthorized'); | ||
| } | ||
| const projects = await ctx.db.query.userProjects.findMany({ | ||
| where: eq(userProjects.userId, input.userId), |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
getPreviewProjects throws plain Error instead of TRPCError and still uses input.userId in the query.
Two concerns:
-
Line 367 throws
new Error('Unauthorized')— inconsistent with the rest of the file which usesTRPCError(e.g.,captureScreenshot,delete). In production, tRPC converts non-TRPCError throws toINTERNAL_SERVER_ERROR(500) and hides the message, so the client gets a generic 500 instead of a proper 401. -
Line 370 uses
input.userIdin thewhereclause even though the check on line 366 guarantees it equalsctx.user.id. Usingctx.user.iddirectly would remain safe even if the guard is later removed or modified.
🔧 Proposed fix
.query(async ({ ctx, input }) => {
// A user may only list their own preview projects — the userId is
// part of the input schema for backwards-compat client shape, but
// is not trusted; the session's own id is the authorization source.
if (input.userId !== ctx.user.id) {
- throw new Error('Unauthorized');
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Cannot view preview projects for another user',
+ });
}
const projects = await ctx.db.query.userProjects.findMany({
- where: eq(userProjects.userId, input.userId),
+ where: eq(userProjects.userId, ctx.user.id),
with: {
project: true,
},
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // A user may only list their own preview projects — the userId is | |
| // part of the input schema for backwards-compat client shape, but | |
| // is not trusted; the session's own id is the authorization source. | |
| if (input.userId !== ctx.user.id) { | |
| throw new Error('Unauthorized'); | |
| } | |
| const projects = await ctx.db.query.userProjects.findMany({ | |
| where: eq(userProjects.userId, input.userId), | |
| // A user may only list their own preview projects — the userId is | |
| // part of the input schema for backwards-compat client shape, but | |
| // is not trusted; the session's own id is the authorization source. | |
| if (input.userId !== ctx.user.id) { | |
| throw new TRPCError({ | |
| code: 'UNAUTHORIZED', | |
| message: 'Cannot view preview projects for another user', | |
| }); | |
| } | |
| const projects = await ctx.db.query.userProjects.findMany({ | |
| where: eq(userProjects.userId, ctx.user.id), |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/client/src/server/api/routers/project/project.ts` around lines 363 -
370, Update getPreviewProjects to throw the file’s established TRPCError type
with the appropriate unauthorized code instead of a plain Error, and use
ctx.user.id rather than input.userId in the userProjects query filter. Preserve
the existing identity validation before querying.
Summary
Closes #3122.
The Drizzle client connects via a Postgres superuser role that is exempt from Supabase RLS, so authorization has to be enforced in tRPC procedure code. A
verifyProjectAccess()helper already existed (project/helper.ts) and was used correctly in a few procedures (project.update,project.delete), but was missing from the large majority of project-scoped procedures — any authenticated user could read, modify, or delete data belonging to other users' projects by supplying an arbitraryprojectId/conversationId/branchId/etc.I audited every router under
server/api/routers/{project,chat}(not just the 15 procedures the original report named) and fixed everything I found unprotected — 25 procedures across 9 files:project.ts:get,getProjectWithCanvas,getPreviewProjects(now pins to the caller's own session id rather than trusting a client-supplieduserId)fork.ts:fork— clones a source project's entire contents (canvas, branches, live sandboxes) into a new project the caller owns; not in the original report, found while auditing this routermember.ts:list,removeinvitation.ts:list,delete,create(createalso unprotected, not in the original report — lets any user invite arbitrary emails into any project)chat/conversation.ts:getAll,get,upsert,update,deletechat/message.ts:getAll,upsert,upsertMany,update,updateCheckpoints,delete,replaceConversationMessages(this one also had a secondary issue: it deletes-then-inserts usinginput.conversationIdfor the delete but each message's own embeddedconversationIdfor the insert — a caller authorized for conversation A could smuggle messages into conversation B via the array. Now forces every inserted message onto the authorizedinput.conversationIdserver-side.)branch.ts:getByProjectId,create,update,delete,fork,createBlanksettings.ts:get,upsert,deleteframe.ts:get,getByCanvas,create,update,deletecreateRequest.ts:getPendingRequest,updateStatusApproach
Added resolve-then-verify helpers to
project/helper.tsfor resources that don't carry aprojectIddirectly, each following the exact pattern the existingverifyProjectAccessalready uses (single query, merged "Unauthorized or not found" error so the check itself can't be used to enumerate resource existence):verifyConversationAccess— conversation →projectIdverifyMessagesAccess— message(s) → conversation →projectId(accepts an array for bulk ops likedelete, verifies every resolved project)verifyBranchAccess— branch →projectIdverifyCanvasAccess— canvas →projectIdverifyFrameAccess— frame → canvas →projectIdverifyInvitationAccess— invitation →projectIdEvery mutation/query call site gets one
await verify*Access(ctx.db, ctx.user.id, ...)at the top, before touching the DB. No new abstractions beyond that — same helper file, same error shape, same call convention already established in the codebase.Not covered here
project/sandbox.tsoperates on raw CodeSandbox provider ids (start/hibernate/delete/fork), notprojectId-keyed DB records — it needs a different resolution path (sandboxId→branches.sandboxId→projectId) and felt like a separate enough concern to not fold into this diff. Happy to follow up with that in a second PR if useful.Test plan
tsc --noEmitrun per changed file (isolated, withoutbun install— this repo'sbun.lockis large and I didn't want to pull the whole dependency tree for a security-fix diff). Every error that surfaced traced back to unresolvable@onlook/dbimports in that isolated setup; I confirmed the specific patterns used (e.g. theSet-dedup + type-guard inverifyMessagesAccess's caller) compile clean under--strictwith equivalent real types in isolation.bun run typecheck+bun testsuite against this branch, and ideally exercising the original repro from Cross-user IDOR across multiple tRPC procedures #3122 against it, since I don't have a local Supabase stack set up to verify end-to-end myself.🤖 Generated with Claude Code
Summary by CodeRabbit