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:
2026-04-29 00:28:01 -04:00
parent ed83f29fe6
commit eab380b76b
6 changed files with 80 additions and 8 deletions

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { users } from "./users";
export const alertRules = sqliteTable("alert_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -10,6 +11,7 @@ export const alertRules = sqliteTable("alert_rules", {
channelId: text("channel_id"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
cooldownMinutes: integer("cooldown_minutes").notNull().default(60),
createdBy: integer("created_by").references(() => users.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});

View File

@@ -10,6 +10,7 @@ export const cohorts = sqliteTable("cohorts", {
size: integer("size").notNull().default(0),
retentionData: text("retention_data"),
metadata: text("metadata"),
createdBy: integer("created_by").references(() => users.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { users } from "./users";
export const scheduledReports = sqliteTable("scheduled_reports", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -11,6 +12,7 @@ export const scheduledReports = sqliteTable("scheduled_reports", {
lastRunAt: integer("last_run_at", { mode: "timestamp" }),
nextRunAt: integer("next_run_at", { mode: "timestamp" }),
metadata: text("metadata"),
createdBy: integer("created_by").references(() => users.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
});

View File

@@ -9,6 +9,7 @@ export interface CohortDefinition {
periodStart: Date;
periodEnd?: Date;
filterCriteria: Record<string, unknown>;
createdBy?: number;
}
export interface CohortAnalysisResult {
@@ -37,6 +38,7 @@ export async function createCohort(
size: 0,
retentionData: null,
metadata: definition.description ? JSON.stringify({ description: definition.description }) : null,
createdBy: definition.createdBy ?? null,
};
const result = await db.insert(cohorts).values(cohort).returning();

View File

@@ -108,6 +108,18 @@ function mapSeverity(
return rule.severity;
}
function escapeSlackMarkdown(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_')
.replace(/~/g, '\\~')
.replace(/`/g, '\\`')
.replace(/\(/g, '\\(');
}
export function formatAlertMessage(
rule: typeof alertRules.$inferSelect,
value: number,
@@ -118,7 +130,9 @@ export function formatAlertMessage(
? rule.threshold.toString()
: rule.threshold.toFixed(2);
return `${rule.name}: ${kpiKey} is ${formattedValue} (${rule.condition} ${formattedThreshold})`;
const safeName = escapeSlackMarkdown(rule.name);
return `${safeName}: ${kpiKey} is ${formattedValue} (${rule.condition} ${formattedThreshold})`;
}
export async function sendSlackAlert(