From 1833182e90357b5a16caa5723d6defa2c142ff09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 21:38:16 +0200 Subject: [PATCH] fix(security): harden input validation schemas and fix SSR sanitize bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blueprint rolePresets: cap array at 100 items to prevent storage abuse - notification CreateManagedNotification: add .max() on title (500), body (2000), type (100), entityType/entityId (200), link (1000), taskAction (200) - settings: add .max() on all string config fields; add regex allowlist (/^[a-zA-Z0-9._-]+$/) on model name fields (geminiModel, azureDalleDeployment, azureOpenAiDeployment) to prevent path manipulation - sanitizeHtml: fix SSR bypass — server-side branch now strips HTML tags instead of returning the raw string unchanged Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/sanitize.ts | 5 +++- .../src/router/blueprint-procedure-support.ts | 2 +- .../src/router/notification-procedure-base.ts | 14 +++++----- packages/api/src/router/settings-support.ts | 26 +++++++++---------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/web/src/lib/sanitize.ts b/apps/web/src/lib/sanitize.ts index 5433bf8..5a801c7 100644 --- a/apps/web/src/lib/sanitize.ts +++ b/apps/web/src/lib/sanitize.ts @@ -6,6 +6,9 @@ import DOMPurify from "dompurify"; * SSR-safe: returns the input unchanged on the server. */ export function sanitizeHtml(dirty: string): string { - if (typeof window === "undefined") return dirty; + if (typeof window === "undefined") { + // Server-side: strip all HTML tags as a safe fallback + return dirty.replace(/<[^>]*>/g, ""); + } return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); } diff --git a/packages/api/src/router/blueprint-procedure-support.ts b/packages/api/src/router/blueprint-procedure-support.ts index f56844d..3a7ef43 100644 --- a/packages/api/src/router/blueprint-procedure-support.ts +++ b/packages/api/src/router/blueprint-procedure-support.ts @@ -54,7 +54,7 @@ export const blueprintUpdateInputSchema = z.object({ export const blueprintRolePresetsInputSchema = z.object({ id: z.string(), - rolePresets: z.array(z.unknown()), + rolePresets: z.array(z.unknown()).max(100), }); export const blueprintBatchDeleteInputSchema = z.object({ diff --git a/packages/api/src/router/notification-procedure-base.ts b/packages/api/src/router/notification-procedure-base.ts index 95237fb..f92bdea 100644 --- a/packages/api/src/router/notification-procedure-base.ts +++ b/packages/api/src/router/notification-procedure-base.ts @@ -180,16 +180,16 @@ export const MarkNotificationReadInputSchema = z.object({ export const CreateManagedNotificationInputSchema = z.object({ userId: z.string(), - type: z.string(), - title: z.string(), - body: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), + type: z.string().max(100), + title: z.string().max(500), + body: z.string().max(2000).optional(), + entityId: z.string().max(200).optional(), + entityType: z.string().max(200).optional(), category: categoryEnum.optional(), priority: priorityEnum.optional(), - link: z.string().optional(), + link: z.string().max(1000).optional(), taskStatus: taskStatusEnum.optional(), - taskAction: z.string().optional(), + taskAction: z.string().max(200).optional(), assigneeId: z.string().optional(), dueDate: z.date().optional(), channel: channelEnum.optional(), diff --git a/packages/api/src/router/settings-support.ts b/packages/api/src/router/settings-support.ts index 955b160..b23f1b9 100644 --- a/packages/api/src/router/settings-support.ts +++ b/packages/api/src/router/settings-support.ts @@ -23,12 +23,12 @@ const SENSITIVE_FIELDS = new Set([ export const settingsUpdateInputSchema = z.object({ aiProvider: z.enum(["openai", "azure"]).optional(), azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")), - azureOpenAiDeployment: z.string().optional(), - azureOpenAiApiKey: z.string().optional(), - azureApiVersion: z.string().optional(), + azureOpenAiDeployment: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(200).optional(), + azureOpenAiApiKey: z.string().max(500).optional(), + azureApiVersion: z.string().max(500).optional(), aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(), aiTemperature: z.number().min(0).max(2).optional(), - aiSummaryPrompt: z.string().optional(), + aiSummaryPrompt: z.string().max(4000).optional(), scoreWeights: z.object({ skillDepth: z.number().min(0).max(1), skillBreadth: z.number().min(0).max(1), @@ -47,21 +47,21 @@ export const settingsUpdateInputSchema = z.object({ { message: "Score weights must sum to 1.0" }, ).optional(), scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).optional(), - smtpHost: z.string().optional(), + smtpHost: z.string().max(500).optional(), smtpPort: z.number().int().min(1).max(65535).optional(), - smtpUser: z.string().optional(), - smtpPassword: z.string().optional(), + smtpUser: z.string().max(500).optional(), + smtpPassword: z.string().max(500).optional(), smtpFrom: z.string().email().optional().or(z.literal("")), smtpTls: z.boolean().optional(), anonymizationEnabled: z.boolean().optional(), - anonymizationDomain: z.string().trim().min(1).optional(), - anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")), + anonymizationDomain: z.string().trim().min(1).max(500).optional(), + anonymizationSeed: z.string().trim().min(1).max(500).optional().or(z.literal("")), anonymizationMode: z.enum(["global"]).optional(), - azureDalleDeployment: z.string().optional(), + azureDalleDeployment: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(200).optional(), azureDalleEndpoint: z.string().url().optional().or(z.literal("")), - azureDalleApiKey: z.string().optional(), - geminiApiKey: z.string().optional(), - geminiModel: z.string().optional(), + azureDalleApiKey: z.string().max(500).optional(), + geminiApiKey: z.string().max(500).optional(), + geminiModel: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(200).optional(), imageProvider: z.enum(["dalle", "gemini"]).optional(), vacationDefaultDays: z.number().int().min(0).max(365).optional(), timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),