Security: - H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl) - H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert - H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3) - registerLimiter exportiert (Crash-Bug: Route.post ohne Callback) - LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects) - LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512) - Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv - DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt UX: - Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten - Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende - Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
228 lines
8.5 KiB
JavaScript
228 lines
8.5 KiB
JavaScript
/**
|
|
* 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/<ts>-<rand>-<name>.<ext>).
|
|
// 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,
|
|
}; |