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:
@@ -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)];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user