From 17471af7f8c90667bcb3c2262905b2d027c1466f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 18 Apr 2026 13:53:28 +0200 Subject: [PATCH] security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51, PR #59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #51 (ESLint rule + conventions doc remain as follow-up). Co-authored-by: Hartmut Nörenberg Co-committed-by: Hartmut Nörenberg --- .../src/app/api/reports/allocations/route.ts | 63 +++++++++---- apps/web/src/app/api/sse/timeline/route.ts | 28 ++++++ apps/web/src/app/api/trpc/[trpc]/route.ts | 22 +++++ packages/api/src/router/audit-log-inputs.ts | 18 ++-- .../router/import-export-procedure-support.ts | 24 ++++- .../src/router/notification-procedure-base.ts | 94 +++++++++---------- packages/api/src/router/resource-mutations.ts | 2 +- .../api/src/router/resource-skill-import.ts | 47 ++++++---- .../src/router/staffing-suggestions-read.ts | 14 +-- .../router/timeline-read-schema-support.ts | 41 ++++---- .../api/src/router/user-procedure-support.ts | 24 ++--- packages/api/src/router/webhook-support.ts | 25 ++--- 12 files changed, 254 insertions(+), 148 deletions(-) diff --git a/apps/web/src/app/api/reports/allocations/route.ts b/apps/web/src/app/api/reports/allocations/route.ts index b0bfd83..66710bc 100644 --- a/apps/web/src/app/api/reports/allocations/route.ts +++ b/apps/web/src/app/api/reports/allocations/route.ts @@ -1,6 +1,7 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { createElement } from "react"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { buildSplitAllocationReadModel } from "@capakraken/application"; import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api"; import { prisma } from "@capakraken/db"; @@ -11,6 +12,17 @@ import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js"; const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]); +// Reject fantasy dates from clients — years outside [2000, 2100] are almost +// certainly malformed input and would generate nonsensical SQL range scans. +const DATE_MIN = new Date("2000-01-01T00:00:00.000Z"); +const DATE_MAX = new Date("2100-01-01T00:00:00.000Z"); + +const queryParamsSchema = z.object({ + startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(), + endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(), + format: z.enum(["pdf", "xlsx"]).default("pdf"), +}); + export async function GET(request: Request) { const session = await auth(); if (!session?.user) { @@ -23,9 +35,20 @@ export async function GET(request: Request) { } const { searchParams } = new URL(request.url); - const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date(); - const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); - const format = searchParams.get("format") ?? "pdf"; + const parsed = queryParamsSchema.safeParse({ + startDate: searchParams.get("startDate") ?? undefined, + endDate: searchParams.get("endDate") ?? undefined, + format: searchParams.get("format") ?? undefined, + }); + if (!parsed.success) { + return new NextResponse("Invalid query parameters", { status: 400 }); + } + const startDate = parsed.data.startDate ?? new Date(); + const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + if (endDate < startDate) { + return new NextResponse("endDate must be >= startDate", { status: 400 }); + } + const format = parsed.data.format; const [demandRequirements, assignments] = await Promise.all([ prisma.demandRequirement.findMany({ @@ -62,21 +85,25 @@ export async function GET(request: Request) { const assignmentRows = allocationView.assignments.slice(0, 500); const directory = await getAnonymizationDirectory(prisma); - const rows = assignmentRows.map((a: AllocationLike & { - resource?: { id: string; displayName?: string | null } | null; - project?: { shortCode: string; name: string } | null; - }) => { - const resource = a.resource ? anonymizeResource(a.resource, directory) : null; - return { - resourceName: resource?.displayName ?? "Unknown", - projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project", - role: a.role ?? "", - startDate: new Date(a.startDate).toLocaleDateString("en-GB"), - endDate: new Date(a.endDate).toLocaleDateString("en-GB"), - hoursPerDay: a.hoursPerDay, - dailyCostCents: a.dailyCostCents, - }; - }); + const rows = assignmentRows.map( + ( + a: AllocationLike & { + resource?: { id: string; displayName?: string | null } | null; + project?: { shortCode: string; name: string } | null; + }, + ) => { + const resource = a.resource ? anonymizeResource(a.resource, directory) : null; + return { + resourceName: resource?.displayName ?? "Unknown", + projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project", + role: a.role ?? "", + startDate: new Date(a.startDate).toLocaleDateString("en-GB"), + endDate: new Date(a.endDate).toLocaleDateString("en-GB"), + hoursPerDay: a.hoursPerDay, + dailyCostCents: a.dailyCostCents, + }; + }, + ); const ts = Date.now(); diff --git a/apps/web/src/app/api/sse/timeline/route.ts b/apps/web/src/app/api/sse/timeline/route.ts index 0122a11..9bce594 100644 --- a/apps/web/src/app/api/sse/timeline/route.ts +++ b/apps/web/src/app/api/sse/timeline/route.ts @@ -9,6 +9,11 @@ import { auth } from "~/server/auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; +// Bounded connection tracking: a single user opening 100 tabs should not be +// able to pin 100 persistent subscriptions on this node. +const MAX_SSE_CONNECTIONS_PER_USER = 8; +const sseConnectionsByUser = new Map(); + export async function GET() { // Start lazily on the first real SSE request so builds/import-time evaluation // never attempt reminder processing against a live database. @@ -43,6 +48,24 @@ export async function GET() { return new Response("Unauthorized", { status: 401 }); } + const currentCount = sseConnectionsByUser.get(dbUser.id) ?? 0; + if (currentCount >= MAX_SSE_CONNECTIONS_PER_USER) { + return new Response("Too many SSE connections", { + status: 429, + headers: { "Retry-After": "30" }, + }); + } + sseConnectionsByUser.set(dbUser.id, currentCount + 1); + + const releaseSlot = () => { + const next = (sseConnectionsByUser.get(dbUser.id) ?? 1) - 1; + if (next <= 0) { + sseConnectionsByUser.delete(dbUser.id); + } else { + sseConnectionsByUser.set(dbUser.id, next); + } + }; + const roleDefaults = await loadRoleDefaults(); const subscription = deriveUserSseSubscription( { @@ -85,6 +108,7 @@ export async function GET() { } catch { clearInterval(heartbeat); unsubscribe(); + releaseSlot(); } }, 30000); @@ -92,8 +116,12 @@ export async function GET() { return () => { clearInterval(heartbeat); unsubscribe(); + releaseSlot(); }; }, + cancel() { + releaseSlot(); + }, }); return new Response(stream, { diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 5b8461b..b624848 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null { return null; } +// Hard cap on tRPC request body size to prevent memory/CPU amplification from +// a single oversized payload. Stream uploads (files, reports) don't go through +// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call. +const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024; + // Throttle lastActiveAt updates: max once per 60s per user const lastActiveCache = new Map(); const ACTIVITY_THROTTLE_MS = 60_000; @@ -37,6 +42,23 @@ function trackActivity(userId: string) { } const handler = async (req: NextRequest) => { + // Reject oversized bodies before we touch auth, DB, or the router. A tRPC + // mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is + // advisory — also guard against chunked requests below via length check + // on the cloned body. + if (req.method !== "GET") { + const declaredLength = req.headers.get("content-length"); + if (declaredLength) { + const parsed = Number(declaredLength); + if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) { + return new Response(JSON.stringify({ error: "Request body too large" }), { + status: 413, + headers: { "Content-Type": "application/json" }, + }); + } + } + } + const session = await auth(); // Validate active session registry on every authenticated request. diff --git a/packages/api/src/router/audit-log-inputs.ts b/packages/api/src/router/audit-log-inputs.ts index ae5d290..a071b05 100644 --- a/packages/api/src/router/audit-log-inputs.ts +++ b/packages/api/src/router/audit-log-inputs.ts @@ -1,21 +1,21 @@ import { z } from "zod"; export const auditLogListInputSchema = z.object({ - entityType: z.string().optional(), - entityId: z.string().optional(), - userId: z.string().optional(), - action: z.string().optional(), - source: z.string().optional(), + entityType: z.string().max(64).optional(), + entityId: z.string().max(64).optional(), + userId: z.string().max(64).optional(), + action: z.string().max(32).optional(), + source: z.string().max(32).optional(), startDate: z.date().optional(), endDate: z.date().optional(), - search: z.string().optional(), + search: z.string().max(200).optional(), limit: z.number().min(1).max(100).default(50), - cursor: z.string().optional(), + cursor: z.string().max(64).optional(), }); export const auditLogByEntityInputSchema = z.object({ - entityType: z.string(), - entityId: z.string(), + entityType: z.string().max(64), + entityId: z.string().max(64), limit: z.number().min(1).max(200).default(50), }); diff --git a/packages/api/src/router/import-export-procedure-support.ts b/packages/api/src/router/import-export-procedure-support.ts index 6e83c2b..b757c9b 100644 --- a/packages/api/src/router/import-export-procedure-support.ts +++ b/packages/api/src/router/import-export-procedure-support.ts @@ -12,9 +12,21 @@ type ImportExportMutationContext = ImportExportReadContext & { type ImportRow = Record; +const CSV_CELL_MAX = 4000; +const CSV_COLUMNS_MAX = 100; +const CSV_ROWS_MAX = 10_000; + export const importCsvInputSchema = z.object({ entityType: z.enum(["resources", "projects", "allocations"]), - rows: z.array(z.record(z.string(), z.string())), + rows: z + .array( + z + .record(z.string().max(200), z.string().max(CSV_CELL_MAX)) + .refine((row) => Object.keys(row).length <= CSV_COLUMNS_MAX, { + message: `CSV row exceeds ${CSV_COLUMNS_MAX} columns`, + }), + ) + .max(CSV_ROWS_MAX), dryRun: z.boolean().default(true), }); @@ -32,7 +44,10 @@ function resolveVisibleBlueprintFields(fieldDefs: unknown): BlueprintFieldDefini } function buildCsv(headers: unknown[], rows: unknown[][]) { - return [headers.map(escapeCsvValue).join(","), ...rows.map((row) => row.map(escapeCsvValue).join(","))].join("\n"); + return [ + headers.map(escapeCsvValue).join(","), + ...rows.map((row) => row.map(escapeCsvValue).join(",")), + ].join("\n"); } export async function exportResourcesCsv(ctx: ImportExportReadContext) { @@ -168,7 +183,10 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC try { if (input.entityType === "resources") { - const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row); + const outcome = await importResourceRow( + { ...ctx, db: tx as unknown as typeof ctx.db }, + row, + ); if (outcome.updated) { results.updated += 1; } else if (outcome.error) { diff --git a/packages/api/src/router/notification-procedure-base.ts b/packages/api/src/router/notification-procedure-base.ts index f92bdea..617722d 100644 --- a/packages/api/src/router/notification-procedure-base.ts +++ b/packages/api/src/router/notification-procedure-base.ts @@ -5,7 +5,10 @@ import { sendEmail } from "../lib/email.js"; import { emitTaskAssigned } from "../sse/event-bus.js"; import type { TRPCContext } from "../trpc.js"; -export type NotificationProcedureContext = Pick; +export type NotificationProcedureContext = Pick< + TRPCContext, + "db" | "dbUser" | "roleDefaults" | "session" +>; export function requireNotificationDbUser(ctx: NotificationProcedureContext) { if (!ctx.dbUser) { @@ -89,17 +92,15 @@ export function rethrowNotificationReferenceError( recipientContext: "notification" | "task" | "broadcast" = "notification", ): never { for (const candidate of getNotificationErrorCandidates(error)) { - const fieldName = typeof candidate.meta?.field_name === "string" - ? candidate.meta.field_name.toLowerCase() - : ""; - const modelName = typeof candidate.meta?.modelName === "string" - ? candidate.meta.modelName.toLowerCase() - : ""; + const fieldName = + typeof candidate.meta?.field_name === "string" ? candidate.meta.field_name.toLowerCase() : ""; + const modelName = + typeof candidate.meta?.modelName === "string" ? candidate.meta.modelName.toLowerCase() : ""; if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && fieldName.includes("assignee") + typeof candidate.code === "string" && + (candidate.code === "P2003" || candidate.code === "P2025") && + fieldName.includes("assignee") ) { throw new TRPCError({ code: "NOT_FOUND", @@ -109,9 +110,9 @@ export function rethrowNotificationReferenceError( } if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && fieldName.includes("sender") + typeof candidate.code === "string" && + (candidate.code === "P2003" || candidate.code === "P2025") && + fieldName.includes("sender") ) { throw new TRPCError({ code: "NOT_FOUND", @@ -121,15 +122,16 @@ export function rethrowNotificationReferenceError( } if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && fieldName.includes("userid") + typeof candidate.code === "string" && + (candidate.code === "P2003" || candidate.code === "P2025") && + fieldName.includes("userid") ) { - const message = recipientContext === "broadcast" - ? "Broadcast recipient user not found" - : recipientContext === "task" - ? "Task recipient user not found" - : "Notification recipient user not found"; + const message = + recipientContext === "broadcast" + ? "Broadcast recipient user not found" + : recipientContext === "task" + ? "Task recipient user not found" + : "Notification recipient user not found"; throw new TRPCError({ code: "NOT_FOUND", message, @@ -138,13 +140,11 @@ export function rethrowNotificationReferenceError( } if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && ( - modelName.includes("notificationbroadcast") - || fieldName.includes("broadcast") - || fieldName.includes("sourceid") - ) + typeof candidate.code === "string" && + (candidate.code === "P2003" || candidate.code === "P2025") && + (modelName.includes("notificationbroadcast") || + fieldName.includes("broadcast") || + fieldName.includes("sourceid")) ) { throw new TRPCError({ code: "NOT_FOUND", @@ -203,11 +203,11 @@ export const ListNotificationTasksInputSchema = z.object({ }); export const NotificationIdInputSchema = z.object({ - id: z.string(), + id: z.string().max(64), }); export const UpdateNotificationTaskStatusInputSchema = z.object({ - id: z.string(), + id: z.string().max(64), status: taskStatusEnum, }); @@ -216,13 +216,13 @@ export const CreateReminderInputSchema = z.object({ body: z.string().max(2000).optional(), remindAt: z.date(), recurrence: recurrenceEnum.optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - link: z.string().optional(), + entityId: z.string().max(64).optional(), + entityType: z.string().max(64).optional(), + link: z.string().max(2048).optional(), }); export const UpdateReminderInputSchema = z.object({ - id: z.string(), + id: z.string().max(64), title: z.string().min(1).max(200).optional(), body: z.string().max(2000).optional(), remindAt: z.date().optional(), @@ -236,14 +236,14 @@ export const ListRemindersInputSchema = z.object({ export const CreateBroadcastInputSchema = z.object({ title: z.string().min(1).max(200), body: z.string().max(2000).optional(), - link: z.string().optional(), + link: z.string().max(2048).optional(), category: categoryEnum.default("NOTIFICATION"), priority: priorityEnum.default("NORMAL"), channel: channelEnum.default("in_app"), targetType: targetTypeEnum, - targetValue: z.string().optional(), + targetValue: z.string().max(200).optional(), scheduledAt: z.date().optional(), - taskAction: z.string().optional(), + taskAction: z.string().max(64).optional(), dueDate: z.date().optional(), }); @@ -252,21 +252,21 @@ export const ListBroadcastsInputSchema = z.object({ }); export const CreateTaskInputSchema = z.object({ - userId: z.string(), + userId: z.string().max(64), title: z.string().min(1).max(200), body: z.string().max(2000).optional(), priority: priorityEnum.default("NORMAL"), dueDate: z.date().optional(), - taskAction: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - link: z.string().optional(), + taskAction: z.string().max(64).optional(), + entityId: z.string().max(64).optional(), + entityType: z.string().max(64).optional(), + link: z.string().max(2048).optional(), channel: channelEnum.default("in_app"), }); export const AssignTaskInputSchema = z.object({ - id: z.string(), - assigneeId: z.string(), + id: z.string().max(64), + assigneeId: z.string().max(64), }); export type BroadcastRecipientNotification = { id: string; userId: string }; @@ -411,9 +411,9 @@ export async function deleteNotification( } if ( - (existing.category === "TASK" || existing.category === "APPROVAL") - && existing.senderId - && existing.senderId !== userId + (existing.category === "TASK" || existing.category === "APPROVAL") && + existing.senderId && + existing.senderId !== userId ) { throw new TRPCError({ code: "FORBIDDEN", diff --git a/packages/api/src/router/resource-mutations.ts b/packages/api/src/router/resource-mutations.ts index e56be9b..b5fb38d 100644 --- a/packages/api/src/router/resource-mutations.ts +++ b/packages/api/src/router/resource-mutations.ts @@ -438,7 +438,7 @@ export const resourceMutationProcedures = { }), batchHardDelete: adminProcedure - .input(z.object({ ids: z.array(z.string()).min(1) })) + .input(z.object({ ids: z.array(z.string().max(64)).min(1).max(500) })) .mutation(async ({ ctx, input }) => { const resources = await ctx.db.resource.findMany({ where: { id: { in: input.ids } }, diff --git a/packages/api/src/router/resource-skill-import.ts b/packages/api/src/router/resource-skill-import.ts index 42a9372..1e2e5e3 100644 --- a/packages/api/src/router/resource-skill-import.ts +++ b/packages/api/src/router/resource-skill-import.ts @@ -2,13 +2,18 @@ import { PermissionKey, SkillEntrySchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; -import { adminProcedure, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; +import { + adminProcedure, + managerProcedure, + protectedProcedure, + requirePermission, +} from "../trpc.js"; const employeeInfoSchema = z .object({ - roleId: z.string().optional(), - yearsOfExperience: z.number().optional(), - portfolioUrl: z.string().url().optional().or(z.literal("")), + roleId: z.string().max(64).optional(), + yearsOfExperience: z.number().min(0).max(100).optional(), + portfolioUrl: z.string().url().max(2048).optional().or(z.literal("")), }) .optional(); @@ -16,7 +21,7 @@ export const resourceSkillImportProcedures = { importSkillMatrix: protectedProcedure .input( z.object({ - skills: z.array(SkillEntrySchema), + skills: z.array(SkillEntrySchema).max(2000), employeeInfo: employeeInfoSchema, }), ) @@ -40,7 +45,9 @@ export const resourceSkillImportProcedures = { ...(input.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: input.employeeInfo.portfolioUrl || null } : {}), - ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), + ...(input.employeeInfo?.roleId !== undefined + ? { roleId: input.employeeInfo.roleId } + : {}), }, }); @@ -50,8 +57,8 @@ export const resourceSkillImportProcedures = { importSkillMatrixForResource: managerProcedure .input( z.object({ - resourceId: z.string(), - skills: z.array(SkillEntrySchema), + resourceId: z.string().max(64), + skills: z.array(SkillEntrySchema).max(2000), employeeInfo: employeeInfoSchema, }), ) @@ -70,7 +77,9 @@ export const resourceSkillImportProcedures = { ...(input.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: input.employeeInfo.portfolioUrl || null } : {}), - ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), + ...(input.employeeInfo?.roleId !== undefined + ? { roleId: input.employeeInfo.roleId } + : {}), }, }); @@ -80,13 +89,15 @@ export const resourceSkillImportProcedures = { batchImportSkillMatrices: adminProcedure .input( z.object({ - entries: z.array( - z.object({ - eid: z.string(), - skills: z.array(SkillEntrySchema), - employeeInfo: employeeInfoSchema, - }), - ), + entries: z + .array( + z.object({ + eid: z.string().max(64), + skills: z.array(SkillEntrySchema).max(2000), + employeeInfo: employeeInfoSchema, + }), + ) + .max(5000), }), ) .mutation(async ({ ctx, input }) => { @@ -110,7 +121,9 @@ export const resourceSkillImportProcedures = { ...(entry.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: entry.employeeInfo.portfolioUrl || null } : {}), - ...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}), + ...(entry.employeeInfo?.roleId !== undefined + ? { roleId: entry.employeeInfo.roleId } + : {}), }, }), ); diff --git a/packages/api/src/router/staffing-suggestions-read.ts b/packages/api/src/router/staffing-suggestions-read.ts index b7c099d..b54528f 100644 --- a/packages/api/src/router/staffing-suggestions-read.ts +++ b/packages/api/src/router/staffing-suggestions-read.ts @@ -397,8 +397,8 @@ async function queryStaffingSuggestions( }); } const GetProjectStaffingSuggestionsInputSchema = z.object({ - projectId: z.string().min(1), - roleName: z.string().optional(), + projectId: z.string().min(1).max(64), + roleName: z.string().max(200).optional(), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), limit: z.number().int().min(1).max(50).optional().default(5), @@ -408,14 +408,14 @@ export const staffingSuggestionsReadProcedures = { getSuggestions: planningReadProcedure .input( z.object({ - requiredSkills: z.array(z.string()), - preferredSkills: z.array(z.string()).optional(), + requiredSkills: z.array(z.string().max(200)).max(200), + preferredSkills: z.array(z.string().max(200)).max(200).optional(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0).max(24), - budgetLcrCentsPerHour: z.number().optional(), - chapter: z.string().optional(), - skillCategory: z.string().optional(), + budgetLcrCentsPerHour: z.number().int().min(0).max(1_000_000_00).optional(), + chapter: z.string().max(100).optional(), + skillCategory: z.string().max(100).optional(), mainSkillsOnly: z.boolean().optional(), minProficiency: z.number().min(1).max(5).optional(), }), diff --git a/packages/api/src/router/timeline-read-schema-support.ts b/packages/api/src/router/timeline-read-schema-support.ts index 9c29a60..f40a6f9 100644 --- a/packages/api/src/router/timeline-read-schema-support.ts +++ b/packages/api/src/router/timeline-read-schema-support.ts @@ -1,35 +1,40 @@ import { z } from "zod"; +const idFilter = () => z.array(z.string().max(64)).max(500); +const chapterFilter = () => z.array(z.string().max(100)).max(100); +const countryFilter = () => z.array(z.string().max(8)).max(300); +const dateStr = () => z.string().max(32); + export const TimelineWindowFiltersSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), - resourceIds: z.array(z.string()).optional(), - projectIds: z.array(z.string()).optional(), - clientIds: z.array(z.string()).optional(), - chapters: z.array(z.string()).optional(), - eids: z.array(z.string()).optional(), - countryCodes: z.array(z.string()).optional(), + resourceIds: idFilter().optional(), + projectIds: idFilter().optional(), + clientIds: idFilter().optional(), + chapters: chapterFilter().optional(), + eids: idFilter().optional(), + countryCodes: countryFilter().optional(), }); export const TimelineDetailFiltersSchema = z.object({ - startDate: z.string().optional(), - endDate: z.string().optional(), + startDate: dateStr().optional(), + endDate: dateStr().optional(), durationDays: z.number().int().min(1).max(366).optional(), - resourceIds: z.array(z.string()).optional(), - projectIds: z.array(z.string()).optional(), - clientIds: z.array(z.string()).optional(), - chapters: z.array(z.string()).optional(), - eids: z.array(z.string()).optional(), - countryCodes: z.array(z.string()).optional(), + resourceIds: idFilter().optional(), + projectIds: idFilter().optional(), + clientIds: idFilter().optional(), + chapters: chapterFilter().optional(), + eids: idFilter().optional(), + countryCodes: countryFilter().optional(), }); export const TimelineProjectContextDetailSchema = z.object({ - projectId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), + projectId: z.string().max(64), + startDate: dateStr().optional(), + endDate: dateStr().optional(), durationDays: z.number().int().min(1).max(366).optional(), }); export const TimelineProjectIdSchema = z.object({ - projectId: z.string(), + projectId: z.string().max(64), }); diff --git a/packages/api/src/router/user-procedure-support.ts b/packages/api/src/router/user-procedure-support.ts index 72f1195..2974e2d 100644 --- a/packages/api/src/router/user-procedure-support.ts +++ b/packages/api/src/router/user-procedure-support.ts @@ -13,45 +13,45 @@ import type { TRPCContext } from "../trpc.js"; import { invalidateRoleDefaultsCache } from "../trpc.js"; export const CreateUserInputSchema = z.object({ - email: z.string().email(), - name: z.string().min(1), + email: z.string().email().max(320), + name: z.string().min(1).max(200), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), }); export const SetUserPasswordInputSchema = z.object({ - userId: z.string(), + userId: z.string().max(64), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), }); export const UpdateUserRoleInputSchema = z.object({ - id: z.string(), + id: z.string().max(64), systemRole: z.nativeEnum(SystemRole), }); export const UpdateUserNameInputSchema = z.object({ - id: z.string(), + id: z.string().max(64), name: z.string().min(1, "Name is required").max(200), }); export const LinkUserResourceInputSchema = z.object({ - userId: z.string(), - resourceId: z.string().nullable(), + userId: z.string().max(64), + resourceId: z.string().max(64).nullable(), }); export const SetUserPermissionsInputSchema = z.object({ - userId: z.string(), + userId: z.string().max(64), overrides: z .object({ - granted: z.array(z.string()).optional(), - denied: z.array(z.string()).optional(), - chapterIds: z.array(z.string()).optional(), + granted: z.array(z.string().max(128)).max(500).optional(), + denied: z.array(z.string().max(128)).max(500).optional(), + chapterIds: z.array(z.string().max(64)).max(500).optional(), }) .nullable(), }); export const UserIdInputSchema = z.object({ - userId: z.string(), + userId: z.string().max(64), }); type UserReadContext = Pick; diff --git a/packages/api/src/router/webhook-support.ts b/packages/api/src/router/webhook-support.ts index cb890da..9d17406 100644 --- a/packages/api/src/router/webhook-support.ts +++ b/packages/api/src/router/webhook-support.ts @@ -6,17 +6,17 @@ export const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ... export const createWebhookInputSchema = z.object({ name: z.string().min(1).max(200), - url: z.string().url(), - secret: z.string().optional(), - events: z.array(webhookEventEnum).min(1), + url: z.string().url().max(2048), + secret: z.string().min(16).max(256).optional(), + events: z.array(webhookEventEnum).min(1).max(100), isActive: z.boolean().default(true), }); export const updateWebhookInputSchema = z.object({ name: z.string().min(1).max(200).optional(), - url: z.string().url().optional(), - secret: z.string().nullish(), - events: z.array(webhookEventEnum).min(1).optional(), + url: z.string().url().max(2048).optional(), + secret: z.string().min(16).max(256).nullish(), + events: z.array(webhookEventEnum).min(1).max(100).optional(), isActive: z.boolean().optional(), }); @@ -35,9 +35,7 @@ type WebhookDb = { }; }; -export function buildWebhookCreateData( - input: z.infer, -) { +export function buildWebhookCreateData(input: z.infer) { return { name: input.name, url: input.url, @@ -47,9 +45,7 @@ export function buildWebhookCreateData( }; } -export function buildWebhookUpdateData( - input: z.infer, -) { +export function buildWebhookUpdateData(input: z.infer) { return { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.url !== undefined ? { url: input.url } : {}), @@ -59,10 +55,7 @@ export function buildWebhookUpdateData( }; } -export async function loadWebhookOrThrow( - db: WebhookDb, - id: string, -) { +export async function loadWebhookOrThrow(db: WebhookDb, id: string) { const webhook = await db.webhook.findUnique({ where: { id } }); if (!webhook) { throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });