/** * Input Validation Module (zod) * * Punkt 2: Schema-based input validation for all API routes. * Provides reusable validation schemas and a middleware helper. */ const { z } = require('zod'); // ============ Auth Schemas ============ // Punkt 11: Password-Policy - min 8 chars, uppercase, lowercase, number const passwordSchema = z.string() .min(8, 'Passwort muss mindestens 8 Zeichen lang sein.') .regex(/[A-Z]/, 'Passwort muss mindestens einen Grossbuchstaben enthalten.') .regex(/[a-z]/, 'Passwort muss mindestens einen Kleinbuchstaben enthalten.') .regex(/[0-9]/, 'Passwort muss mindestens eine Zahl enthalten.'); const registerSchema = z.object({ email: z.string().email('Ungueltige E-Mail-Adresse.'), password: passwordSchema, name: z.string().max(100).optional().default(''), // VULN-FIX: role removed - always 'user' on register, never trust client }); const loginSchema = z.object({ email: z.string().min(1, 'E-Mail ist erforderlich.'), password: z.string().min(1, 'Passwort ist erforderlich.'), }); // ============ User Schemas ============ const createUserSchema = z.object({ email: z.string().email('Ungueltige E-Mail-Adresse.'), password: passwordSchema, name: z.string().max(100).optional().default(''), role: z.enum(['admin', 'user']).optional().default('user'), status: z.enum(['aktiv', 'inaktiv']).optional().default('aktiv'), }); const updateUserSchema = z.object({ email: z.string().email('Ungueltige E-Mail-Adresse.').optional(), name: z.string().max(100).optional(), password: passwordSchema.optional(), current_password: z.string().optional(), role: z.enum(['admin', 'user']).optional(), status: z.enum(['aktiv', 'inaktiv']).optional(), }); // ============ Template Schemas ============ const templateStepSchema = z.object({ page_num: z.number().int().min(1).optional().default(1), label: z.string().min(1, 'Label ist erforderlich.').max(200), type: z.enum(['checkbox', 'text_input', 'file_upload', 'email', 'dropdown', 'ad_password', 'ad_displayname']), step_order: z.number().int().min(0).optional(), email_domain: z.string().optional(), email_source_fields: z.string().optional(), dropdown_options: z.string().optional(), ad_field: z.string().optional(), ad_prefix: z.string().optional(), hidden: z.boolean().optional().default(false), }); const createTemplateSchema = z.object({ name: z.string().min(1, 'Name ist erforderlich.').max(200), description: z.string().max(1000).optional().default(''), is_assignable: z.boolean().optional().default(false), allows_file_upload: z.boolean().optional().default(false), ad_create: z.boolean().optional().default(false), steps: z.array(templateStepSchema).optional().default([]), }); const updateTemplateSchema = z.object({ name: z.string().min(1, 'Name ist erforderlich.').max(200), description: z.string().max(1000).optional().default(''), is_assignable: z.boolean().optional().default(false), allows_file_upload: z.boolean().optional().default(false), ad_create: z.boolean().optional().default(false), steps: z.array(templateStepSchema).optional().default([]), }); // ============ Task Schemas ============ // H1: Whitelist für Datei-Pfade — nur Pfade akzeptieren, die exakt vom // Upload-Endpoint emittiert werden (/api/upload/uploads/--.). // Verhindert Stored XSS über javascript:/data:-URLs in Task-Dateilinks. // Endungen beschränkt auf die vom Uploader erlaubten Typen (inkl. .bin-Fallback // für abgelehnte Original-Endungen). Der Name-Teil ist bewusst `*` (nicht `+`), // da der Uploader auch Dateien mit leerem Basisnamen erzeugen kann (z.B. ".pdf"). const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/; const filePathSchema = z.string().regex(UPLOAD_PATH_PATTERN, 'Ungueltiger Dateipfad.'); /** * H1: Prüft, ob ein Pfad/URL ein legitimer Upload-Link ist. * Wird auch in routes/tasks.js verwendet, um `value`-Felder von * file_upload-Steps zu validieren (dort ist der Step-Typ erst serverseitig bekannt). */ function isSafeUploadPath(path) { return typeof path === 'string' && UPLOAD_PATH_PATTERN.test(path); } const createTaskSchema = z.object({ template_id: z.number().int().positive('Template-ID ist erforderlich.'), title: z.string().min(1, 'Titel ist erforderlich.').max(500), user_id: z.number().int().positive().optional(), file_path: filePathSchema.optional(), // V9: Limit task values array to prevent DoS via huge payloads values: z.array(z.object({ step_id: z.number().int().positive().optional(), value: z.string().max(10000).optional(), is_checked: z.boolean().optional(), file_path: filePathSchema.optional(), })).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]), }); const updateTaskStatusSchema = z.object({ status: z.enum(['offen', 'erledigt'], { message: 'Status muss "offen" oder "erledigt" sein.' }), }); const updateTaskValuesSchema = z.object({ values: z.array(z.object({ id: z.number().int().positive(), value: z.string().optional(), is_checked: z.boolean().optional(), })).min(1, 'Mindestens ein Wert ist erforderlich.'), }); const addTaskFieldSchema = z.object({ label: z.string().min(1, 'Label ist erforderlich.').max(200), type: z.enum(['text_input', 'checkbox', 'dropdown', 'email']).optional().default('text_input'), value: z.string().optional().default(''), page_num: z.number().int().min(1).optional().default(1), dropdown_options: z.string().optional().default(''), ad_field: z.string().optional().default(''), hidden: z.boolean().optional().default(false), email_source_fields: z.string().optional().default(''), }); // ============ AD Schemas ============ const createADUserSchema = z.object({ ou: z.string().min(1, 'OU ist erforderlich.'), vorname: z.string().min(1, 'Vorname ist erforderlich.').max(100), nachname: z.string().min(1, 'Nachname ist erforderlich.').max(100), username: z.string().min(1, 'Anmeldename ist erforderlich.').max(50), password: z.string().min(1, 'Passwort ist erforderlich.').min(8, 'Passwort muss mindestens 8 Zeichen lang sein.'), email: z.string().email().optional(), department: z.string().max(100).optional(), telefon: z.string().max(50).optional(), titel: z.string().max(100).optional(), displayName: z.string().max(200).optional(), physicalDeliveryOfficeName: z.string().max(100).optional(), company: z.string().max(100).optional(), description: z.string().max(500).optional(), wWWHomePage: z.string().max(200).optional(), streetAddress: z.string().max(200).optional(), postOfficeBox: z.string().max(50).optional(), l: z.string().max(100).optional(), st: z.string().max(100).optional(), postalCode: z.string().max(20).optional(), c: z.string().max(2).optional(), groups: z.array(z.string()).optional(), }); const deleteADUserSchema = z.object({ dn: z.string().min(1, 'DN ist erforderlich.'), }); // ============ Search/Query Schemas ============ const paginationSchema = z.object({ page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(100).optional().default(20), }); const searchSchema = z.object({ search: z.string().max(100).optional(), }); // ============ Validation Middleware ============ function validate(schema) { return (req, res, next) => { try { const result = schema.safeParse(req.body); if (!result.success) { const errors = result.error.errors.map(e => e.message).join(', '); return res.status(400).json({ error: errors }); } req.validatedBody = result.data; next(); } catch (err) { return res.status(400).json({ error: 'Ungueltige Eingabe.' }); } }; } function validateQuery(schema) { return (req, res, next) => { try { const result = schema.safeParse(req.query); if (!result.success) { const errors = result.error.errors.map(e => e.message).join(', '); return res.status(400).json({ error: errors }); } req.validatedQuery = result.data; next(); } catch (err) { return res.status(400).json({ error: 'Ungueltige Abfrage.' }); } }; } module.exports = { // Schemas registerSchema, loginSchema, createUserSchema, updateUserSchema, createTemplateSchema, updateTemplateSchema, templateStepSchema, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, createADUserSchema, deleteADUserSchema, paginationSchema, searchSchema, // Middleware validate, validateQuery, isSafeUploadPath, };