Fix FRE-622 security findings: IDOR, auth, markdown injection, email validation
H-1: Add createdBy to alertRules, IDOR check on update/delete H-2: Add createdBy to scheduledReports, IDOR check on update H-3: Add createdBy to cohorts, IDOR check on addCohortMember M-1: Change submitNPSResponse to protectedProcedure M-2: Escape Slack Markdown special chars in alert rule names M-3: Change getAllLatestKPIs, getAlertRules, getAlerts, getNPSResponses to protectedProcedure L-2: Add email regex validation to recipients field Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -88,7 +88,7 @@ export const analyticsRouter = {
|
||||
return await getLatestKPI(ctx.db!, input.kpiKey as KPIKey);
|
||||
}),
|
||||
|
||||
getAllLatestKPIs: publicProcedure.query(async ({ ctx }) => {
|
||||
getAllLatestKPIs: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getAllLatestKPIs(ctx.db!);
|
||||
}),
|
||||
|
||||
@@ -147,7 +147,7 @@ export const analyticsRouter = {
|
||||
|
||||
// --- Alert Endpoints ---
|
||||
|
||||
getAlertRules: publicProcedure.query(async ({ ctx }) => {
|
||||
getAlertRules: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db!.select().from(alertRules).orderBy(desc(alertRules.createdAt));
|
||||
}),
|
||||
|
||||
@@ -171,6 +171,7 @@ export const analyticsRouter = {
|
||||
channelId: input.channelId ?? null,
|
||||
isActive: true,
|
||||
cooldownMinutes: input.cooldownMinutes,
|
||||
createdBy: ctx.userId,
|
||||
}).returning();
|
||||
return result[0];
|
||||
}),
|
||||
@@ -188,6 +189,18 @@ export const analyticsRouter = {
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, ...updates } = input;
|
||||
const existing = await ctx.db!
|
||||
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.id, id))
|
||||
.limit(1);
|
||||
const rule = existing[0];
|
||||
if (!rule) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
|
||||
}
|
||||
if (rule.createdBy !== ctx.userId) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
|
||||
}
|
||||
const result = await ctx.db!
|
||||
.update(alertRules)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
@@ -199,11 +212,23 @@ export const analyticsRouter = {
|
||||
deleteAlertRule: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existing = await ctx.db!
|
||||
.select({ id: alertRules.id, createdBy: alertRules.createdBy })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.id, input.id))
|
||||
.limit(1);
|
||||
const rule = existing[0];
|
||||
if (!rule) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Alert rule not found' });
|
||||
}
|
||||
if (rule.createdBy !== ctx.userId) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the rule owner' });
|
||||
}
|
||||
await ctx.db!.delete(alertRules).where(eq(alertRules.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getAlerts: publicProcedure
|
||||
getAlerts: protectedProcedure
|
||||
.input(z.object({
|
||||
severity: AlertSeveritySchema.optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
@@ -274,7 +299,7 @@ export const analyticsRouter = {
|
||||
name: z.string().min(1).max(200),
|
||||
reportType: ReportTypeSchema,
|
||||
schedule: ScheduleSchema,
|
||||
recipients: z.string(),
|
||||
recipients: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email'),
|
||||
format: ReportFormatSchema,
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}))
|
||||
@@ -287,6 +312,7 @@ export const analyticsRouter = {
|
||||
format: input.format,
|
||||
isActive: true,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
return report;
|
||||
}),
|
||||
@@ -297,12 +323,24 @@ export const analyticsRouter = {
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
reportType: ReportTypeSchema.optional(),
|
||||
schedule: ScheduleSchema.optional(),
|
||||
recipients: z.string().optional(),
|
||||
recipients: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+(,[^\s@]+@[^\s@]+\.[^\s@]+)*$/, 'Each recipient must be a valid email').optional(),
|
||||
format: ReportFormatSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { id, ...updates } = input;
|
||||
const existing = await ctx.db!
|
||||
.select({ id: scheduledReports.id, createdBy: scheduledReports.createdBy })
|
||||
.from(scheduledReports)
|
||||
.where(eq(scheduledReports.id, id))
|
||||
.limit(1);
|
||||
const report = existing[0];
|
||||
if (!report) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Scheduled report not found' });
|
||||
}
|
||||
if (report.createdBy !== ctx.userId) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the report owner' });
|
||||
}
|
||||
const result = await ctx.db!
|
||||
.update(scheduledReports)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
@@ -341,6 +379,7 @@ export const analyticsRouter = {
|
||||
periodStart: new Date(input.periodStart),
|
||||
periodEnd: input.periodEnd ? new Date(input.periodEnd) : undefined,
|
||||
filterCriteria: input.filterCriteria,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
return cohort;
|
||||
}),
|
||||
@@ -351,6 +390,18 @@ export const analyticsRouter = {
|
||||
userId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existing = await ctx.db!
|
||||
.select({ id: cohorts.id, createdBy: cohorts.createdBy })
|
||||
.from(cohorts)
|
||||
.where(eq(cohorts.id, input.cohortId))
|
||||
.limit(1);
|
||||
const cohort = existing[0];
|
||||
if (!cohort) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'NOT_FOUND', message: 'Cohort not found' });
|
||||
}
|
||||
if (cohort.createdBy !== ctx.userId) {
|
||||
throw new (await import('./router')).TRPCError({ code: 'FORBIDDEN', message: 'Not the cohort owner' });
|
||||
}
|
||||
await addCohortMember(ctx.db!, input.cohortId, input.userId);
|
||||
const size = await getCohortSize(ctx.db!, input.cohortId);
|
||||
return { success: true, cohortSize: size };
|
||||
@@ -382,7 +433,7 @@ export const analyticsRouter = {
|
||||
|
||||
// --- NPS Endpoints ---
|
||||
|
||||
submitNPSResponse: publicProcedure
|
||||
submitNPSResponse: protectedProcedure
|
||||
.input(z.object({
|
||||
score: z.number().int().min(0).max(10),
|
||||
userId: z.number().int().positive().optional(),
|
||||
@@ -414,7 +465,7 @@ export const analyticsRouter = {
|
||||
);
|
||||
}),
|
||||
|
||||
getNPSResponses: publicProcedure
|
||||
getNPSResponses: protectedProcedure
|
||||
.input(z.object({
|
||||
category: z.enum(['detractor', 'passive', 'promoter']).optional(),
|
||||
periodStart: z.string().datetime().optional(),
|
||||
|
||||
Reference in New Issue
Block a user