FRE-588: Fix IDOR vulnerabilities and security findings
H1: Add verifyScriptAccess/verifyRevisionAccess to all 14 revisions endpoints H2: Add verifyProjectAccess to listScripts and searchScripts M2: Add cascade delete for projectMembers on project deletion M4: Replace plain Error throws with TRPCError for consistent error handling M5: Use crypto.randomUUID for team ID generation (was Date.now + Math.random) L1: Add 100KB content size limit on revision content L2: Add unique constraint to script slug column L3: Update hasProjectAccess middleware to check project membership
This commit is contained in:
@@ -5,8 +5,71 @@ import type { DrizzleDB } from '../../src/db/config/migrations';
|
||||
import {
|
||||
revisions,
|
||||
revisionChanges,
|
||||
scripts,
|
||||
projects,
|
||||
projectMembers,
|
||||
} from '../../src/db/schema';
|
||||
|
||||
// H1 fix: verifies user has access to the project owning the script a revision belongs to
|
||||
async function verifyScriptAccess(
|
||||
db: DrizzleDB,
|
||||
scriptId: number,
|
||||
userId: number
|
||||
) {
|
||||
const scriptRows = await db
|
||||
.select({ id: scripts.id, projectId: scripts.projectId })
|
||||
.from(scripts)
|
||||
.where(eq(scripts.id, scriptId));
|
||||
|
||||
const script = scriptRows[0];
|
||||
if (!script) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` });
|
||||
}
|
||||
|
||||
const projectRows = await db
|
||||
.select({ id: projects.id, ownerId: projects.ownerId })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, script.projectId));
|
||||
|
||||
const project = projectRows[0];
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${script.projectId} not found` });
|
||||
}
|
||||
|
||||
if (project.ownerId === userId) return { script, project };
|
||||
|
||||
const memberRows = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, script.projectId), eq(projectMembers.userId, userId)));
|
||||
|
||||
if (memberRows.length === 0) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to script ${scriptId}` });
|
||||
}
|
||||
|
||||
return { script, project };
|
||||
}
|
||||
|
||||
// Resolves revision → script → project and verifies user access
|
||||
async function verifyRevisionAccess(
|
||||
db: DrizzleDB,
|
||||
revisionId: number,
|
||||
userId: number
|
||||
) {
|
||||
const revisionRows = await db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, revisionId));
|
||||
|
||||
const revision = revisionRows[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${revisionId} not found` });
|
||||
}
|
||||
|
||||
const { script, project } = await verifyScriptAccess(db, revision.scriptId, userId);
|
||||
return { revision, script, project };
|
||||
}
|
||||
|
||||
function computeDiffForContent(
|
||||
db: DrizzleDB,
|
||||
oldContent: string,
|
||||
@@ -94,8 +157,9 @@ export const revisionsRouter = {
|
||||
branchName: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
const conditions = [eq(revisions.scriptId, input.scriptId)];
|
||||
|
||||
|
||||
if (input.branchName) {
|
||||
conditions.push(eq(revisions.branchName, input.branchName));
|
||||
}
|
||||
@@ -105,7 +169,7 @@ export const revisionsRouter = {
|
||||
.from(revisions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(revisions.versionNumber));
|
||||
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -114,15 +178,7 @@ export const revisionsRouter = {
|
||||
id: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const rows = await ctx.db!
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.id));
|
||||
|
||||
const revision = rows[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
|
||||
return revision;
|
||||
}),
|
||||
|
||||
@@ -131,7 +187,7 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(255),
|
||||
summary: z.string().max(2000).optional(),
|
||||
content: z.string(),
|
||||
content: z.string().max(100000),
|
||||
branchName: z.string().default('main'),
|
||||
parentRevisionId: z.number().int().positive().optional(),
|
||||
}))
|
||||
@@ -139,6 +195,7 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
|
||||
const nextVersion = await getLatestVersionForScript(
|
||||
ctx.db,
|
||||
@@ -193,14 +250,7 @@ export const revisionsRouter = {
|
||||
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.id));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(revisions)
|
||||
@@ -222,15 +272,13 @@ export const revisionsRouter = {
|
||||
id: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!);
|
||||
|
||||
const result = await ctx.db
|
||||
.delete(revisions)
|
||||
.where(eq(revisions.id, input.id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.delete(revisionChanges)
|
||||
.where(eq(revisionChanges.revisionId, input.id));
|
||||
@@ -243,14 +291,7 @@ export const revisionsRouter = {
|
||||
revisionId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const revisionResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = revisionResult[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
|
||||
|
||||
const changes = await ctx.db
|
||||
.select()
|
||||
@@ -267,24 +308,8 @@ export const revisionsRouter = {
|
||||
targetRevisionId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const baseResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.baseRevisionId));
|
||||
const baseRevision = baseResult[0];
|
||||
|
||||
const targetResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.targetRevisionId));
|
||||
const targetRevision = targetResult[0];
|
||||
|
||||
if (!baseRevision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Base revision ${input.baseRevisionId} not found` });
|
||||
}
|
||||
if (!targetRevision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Target revision ${input.targetRevisionId} not found` });
|
||||
}
|
||||
const { revision: baseRevision } = await verifyRevisionAccess(ctx.db!, input.baseRevisionId, ctx.userId!);
|
||||
const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.targetRevisionId, ctx.userId!);
|
||||
|
||||
const oldLines = baseRevision.content.split('\n');
|
||||
const newLines = targetRevision.content.split('\n');
|
||||
@@ -328,15 +353,7 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(revisions)
|
||||
@@ -361,15 +378,7 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const result = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const revision = result[0];
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
const { revision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
|
||||
|
||||
const newSummary = input.reason
|
||||
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
|
||||
@@ -399,15 +408,8 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const targetResult = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
.where(eq(revisions.id, input.revisionId));
|
||||
const targetRevision = targetResult[0];
|
||||
if (!targetRevision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` });
|
||||
}
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!);
|
||||
|
||||
if (targetRevision.scriptId !== input.scriptId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' });
|
||||
@@ -449,6 +451,7 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
const scriptRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
@@ -484,6 +487,7 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
const scriptRevisions = await ctx.db
|
||||
.select()
|
||||
.from(revisions)
|
||||
@@ -522,6 +526,7 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
|
||||
const existingResult = await ctx.db
|
||||
.select({ id: revisions.id })
|
||||
@@ -601,6 +606,7 @@ export const revisionsRouter = {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' });
|
||||
}
|
||||
await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!);
|
||||
|
||||
if (input.sourceBranch === input.targetBranch) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot merge a branch into itself' });
|
||||
|
||||
Reference in New Issue
Block a user