fix(security): harden input validation schemas and fix SSR sanitize bypass

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:38:16 +02:00
parent df191d1e03
commit 1833182e90
4 changed files with 25 additions and 22 deletions
+4 -1
View File
@@ -6,6 +6,9 @@ import DOMPurify from "dompurify";
* SSR-safe: returns the input unchanged on the server. * SSR-safe: returns the input unchanged on the server.
*/ */
export function sanitizeHtml(dirty: string): string { 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: [] }); return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
} }
@@ -54,7 +54,7 @@ export const blueprintUpdateInputSchema = z.object({
export const blueprintRolePresetsInputSchema = z.object({ export const blueprintRolePresetsInputSchema = z.object({
id: z.string(), id: z.string(),
rolePresets: z.array(z.unknown()), rolePresets: z.array(z.unknown()).max(100),
}); });
export const blueprintBatchDeleteInputSchema = z.object({ export const blueprintBatchDeleteInputSchema = z.object({
@@ -180,16 +180,16 @@ export const MarkNotificationReadInputSchema = z.object({
export const CreateManagedNotificationInputSchema = z.object({ export const CreateManagedNotificationInputSchema = z.object({
userId: z.string(), userId: z.string(),
type: z.string(), type: z.string().max(100),
title: z.string(), title: z.string().max(500),
body: z.string().optional(), body: z.string().max(2000).optional(),
entityId: z.string().optional(), entityId: z.string().max(200).optional(),
entityType: z.string().optional(), entityType: z.string().max(200).optional(),
category: categoryEnum.optional(), category: categoryEnum.optional(),
priority: priorityEnum.optional(), priority: priorityEnum.optional(),
link: z.string().optional(), link: z.string().max(1000).optional(),
taskStatus: taskStatusEnum.optional(), taskStatus: taskStatusEnum.optional(),
taskAction: z.string().optional(), taskAction: z.string().max(200).optional(),
assigneeId: z.string().optional(), assigneeId: z.string().optional(),
dueDate: z.date().optional(), dueDate: z.date().optional(),
channel: channelEnum.optional(), channel: channelEnum.optional(),
+13 -13
View File
@@ -23,12 +23,12 @@ const SENSITIVE_FIELDS = new Set([
export const settingsUpdateInputSchema = z.object({ export const settingsUpdateInputSchema = z.object({
aiProvider: z.enum(["openai", "azure"]).optional(), aiProvider: z.enum(["openai", "azure"]).optional(),
azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")), azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")),
azureOpenAiDeployment: z.string().optional(), azureOpenAiDeployment: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(200).optional(),
azureOpenAiApiKey: z.string().optional(), azureOpenAiApiKey: z.string().max(500).optional(),
azureApiVersion: z.string().optional(), azureApiVersion: z.string().max(500).optional(),
aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(), aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(),
aiTemperature: z.number().min(0).max(2).optional(), aiTemperature: z.number().min(0).max(2).optional(),
aiSummaryPrompt: z.string().optional(), aiSummaryPrompt: z.string().max(4000).optional(),
scoreWeights: z.object({ scoreWeights: z.object({
skillDepth: z.number().min(0).max(1), skillDepth: z.number().min(0).max(1),
skillBreadth: 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" }, { message: "Score weights must sum to 1.0" },
).optional(), ).optional(),
scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).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(), smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().optional(), smtpUser: z.string().max(500).optional(),
smtpPassword: z.string().optional(), smtpPassword: z.string().max(500).optional(),
smtpFrom: z.string().email().optional().or(z.literal("")), smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(), smtpTls: z.boolean().optional(),
anonymizationEnabled: z.boolean().optional(), anonymizationEnabled: z.boolean().optional(),
anonymizationDomain: z.string().trim().min(1).optional(), anonymizationDomain: z.string().trim().min(1).max(500).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")), anonymizationSeed: z.string().trim().min(1).max(500).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(), 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("")), azureDalleEndpoint: z.string().url().optional().or(z.literal("")),
azureDalleApiKey: z.string().optional(), azureDalleApiKey: z.string().max(500).optional(),
geminiApiKey: z.string().optional(), geminiApiKey: z.string().max(500).optional(),
geminiModel: z.string().optional(), geminiModel: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(200).optional(),
imageProvider: z.enum(["dalle", "gemini"]).optional(), imageProvider: z.enum(["dalle", "gemini"]).optional(),
vacationDefaultDays: z.number().int().min(0).max(365).optional(), vacationDefaultDays: z.number().int().min(0).max(365).optional(),
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(), timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),