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:
Senior Engineer
2026-04-29 06:57:20 -04:00
committed by Michael Freno
parent eab380b76b
commit c142611470
7 changed files with 154 additions and 114 deletions

View File

@@ -1,4 +1,4 @@
import { protectedProcedure } from './router';
import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, like, sql, inArray } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
@@ -7,12 +7,43 @@ import {
revisions,
revisionChanges,
projects,
projectMembers,
} from '../../src/db/schema';
function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
// H2 fix: verifies user has access to a project (owner or member)
async function verifyProjectAccess(
db: DrizzleDB,
projectId: number,
userId: number
) {
const projectRows = await db
.select({ id: projects.id, ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId));
const project = projectRows[0];
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` });
}
if (project.ownerId === userId) return project;
const memberRows = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` });
}
return project;
}
async function verifyScriptOwnership(
db: DrizzleDB,
scriptId: number,
@@ -24,18 +55,11 @@ async function verifyScriptOwnership(
const script = scriptRows[0];
if (!script) {
throw new Error(`Script ${scriptId} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` });
}
const projectRows = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, script.projectId));
const project = projectRows[0];
if (!project || project.ownerId !== userId) {
throw new Error(`You do not have access to script ${scriptId}`);
}
await verifyProjectAccess(db, script.projectId, userId);
return script;
}
@@ -43,10 +67,8 @@ export const scriptsRouter = {
listScripts: protectedProcedure
.input(z.object({ projectId: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
return await ctx.db!.select()
.from(scripts)
.where(eq(scripts.projectId, input.projectId))
@@ -56,14 +78,14 @@ export const scriptsRouter = {
getScript: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input, ctx }) => {
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
const rows = await ctx.db!.select()
.from(scripts)
.where(eq(scripts.id, input.id));
const script = rows[0];
if (!script) {
throw new Error(`Script ${input.id} not found`);
throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${input.id} not found` });
}
await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!);
return script;
}),
@@ -76,12 +98,7 @@ export const scriptsRouter = {
status: z.enum(['draft', 'revision', 'final', 'published']).optional(),
}))
.mutation(async ({ input, ctx }) => {
const projectRows = await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
if (!projectRows[0]) {
throw new Error(`Project ${input.projectId} not found`);
}
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
const result = await ctx.db!.insert(scripts)
.values({
@@ -156,9 +173,7 @@ export const scriptsRouter = {
query: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
await ctx.db!.select()
.from(projects)
.where(eq(projects.id, input.projectId));
await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!);
const conditions: any[] = [eq(scripts.projectId, input.projectId)];