/** * TASKMATE - Input Validierung * ============================ * Schutz vor SQL-Injection und XSS */ const sanitizeHtml = require('sanitize-html'); const createDOMPurify = require('dompurify'); const { JSDOM } = require('jsdom'); // DOMPurify für Server-side Rendering initialisieren const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); /** * HTML-Entities dekodieren */ function decodeHtmlEntities(str) { if (typeof str !== 'string') return str; const entities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ''': "'" }; return str.replace(/&(amp|lt|gt|quot|#039|#x27|apos);/g, match => entities[match] || match); } /** * HTML-Tags entfernen (für reine Text-Felder) * Wichtig: sanitize-html encoded &-Zeichen zu &, daher dekodieren wir danach */ function stripHtml(input) { if (typeof input !== 'string') return input; const sanitized = sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }).trim(); // Entities wieder dekodieren, da sanitize-html sie encoded return decodeHtmlEntities(sanitized); } /** * Markdown-sichere Bereinigung mit DOMPurify (doppelte Sicherheit) */ function sanitizeMarkdown(input) { if (typeof input !== 'string') return input; // Erste Bereinigung mit sanitize-html const firstPass = sanitizeHtml(input, { allowedTags: [ 'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], allowedAttributes: { 'a': ['href', 'title', 'target', 'rel'] }, allowedSchemes: ['http', 'https', 'mailto'], transformTags: { 'a': (tagName, attribs) => { return { tagName: 'a', attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer' } }; } } }); // Zweite Bereinigung mit DOMPurify (zusätzliche Sicherheit) return DOMPurify.sanitize(firstPass, { ALLOWED_TAGS: [ 'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], ALLOWED_ATTR: ['href', 'title', 'target', 'rel'], ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i }); } /** * Objekt rekursiv bereinigen */ function sanitizeObject(obj, options = {}) { if (obj === null || obj === undefined) return obj; if (typeof obj === 'string') { return options.allowHtml ? sanitizeMarkdown(obj) : stripHtml(obj); } if (Array.isArray(obj)) { return obj.map(item => sanitizeObject(item, options)); } if (typeof obj === 'object') { const sanitized = {}; for (const [key, value] of Object.entries(obj)) { // Bestimmte Felder dürfen Markdown enthalten const allowHtml = ['description', 'content'].includes(key); // Passwort-Felder NICHT sanitizen (Sonderzeichen erhalten) const skipSanitization = ['password', 'oldPassword', 'newPassword', 'confirmPassword'].includes(key); if (skipSanitization) { sanitized[key] = value; // Passwort unverändert lassen } else { sanitized[key] = sanitizeObject(value, { allowHtml }); } } return sanitized; } return obj; } /** * Validierungsfunktionen */ const validators = { /** * Pflichtfeld prüfen */ required: (value, fieldName) => { if (value === undefined || value === null || value === '') { return `${fieldName} ist erforderlich`; } return null; }, /** * Minimale Länge prüfen */ minLength: (value, min, fieldName) => { if (typeof value === 'string' && value.length < min) { return `${fieldName} muss mindestens ${min} Zeichen haben`; } return null; }, /** * Maximale Länge prüfen */ maxLength: (value, max, fieldName) => { if (typeof value === 'string' && value.length > max) { return `${fieldName} darf maximal ${max} Zeichen haben`; } return null; }, /** * E-Mail-Format prüfen */ email: (value, fieldName) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (value && !emailRegex.test(value)) { return `${fieldName} muss eine gültige E-Mail-Adresse sein`; } return null; }, /** * URL-Format prüfen (erweiterte Sicherheit) */ url: (value, fieldName) => { try { if (value) { const url = new URL(value); // Nur HTTP/HTTPS erlauben if (!['http:', 'https:'].includes(url.protocol)) { return `${fieldName} muss HTTP oder HTTPS verwenden`; } // Localhost und private IPs blocken (SSRF-Schutz) const hostname = url.hostname; if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.')) { return `${fieldName} darf nicht auf lokale Adressen verweisen`; } // JavaScript URLs blocken if (url.href.toLowerCase().startsWith('javascript:')) { return `${fieldName} enthält ungültigen JavaScript-Code`; } } return null; } catch { return `${fieldName} muss eine gültige URL sein`; } }, /** * Integer prüfen */ integer: (value, fieldName) => { if (value !== undefined && value !== null && !Number.isInteger(Number(value))) { return `${fieldName} muss eine ganze Zahl sein`; } return null; }, /** * Positiver Integer prüfen */ positiveInteger: (value, fieldName) => { const num = Number(value); if (value !== undefined && value !== null && (!Number.isInteger(num) || num < 0)) { return `${fieldName} muss eine positive ganze Zahl sein`; } return null; }, /** * Datum prüfen (YYYY-MM-DD) */ date: (value, fieldName) => { if (value) { const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(value)) { return `${fieldName} muss im Format YYYY-MM-DD sein`; } const date = new Date(value); if (isNaN(date.getTime())) { return `${fieldName} ist kein gültiges Datum`; } } return null; }, /** * Enum-Wert prüfen */ enum: (value, allowedValues, fieldName) => { if (value && !allowedValues.includes(value)) { return `${fieldName} muss einer der folgenden Werte sein: ${allowedValues.join(', ')}`; } return null; }, /** * Hex-Farbe prüfen */ hexColor: (value, fieldName) => { if (value) { const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; if (!hexRegex.test(value)) { return `${fieldName} muss eine gültige Hex-Farbe sein (z.B. #FF0000)`; } } return null; } }; /** * Passwort-Richtlinien prüfen */ function validatePassword(password) { const errors = []; const minLength = 8; if (!password || password.length < minLength) { errors.push(`Passwort muss mindestens ${minLength} Zeichen haben`); } if (password && !/[a-z]/.test(password)) { errors.push('Passwort muss mindestens einen Kleinbuchstaben enthalten'); } if (password && !/[A-Z]/.test(password)) { errors.push('Passwort muss mindestens einen Großbuchstaben enthalten'); } if (password && !/[0-9]/.test(password)) { errors.push('Passwort muss mindestens eine Zahl enthalten'); } return errors; } /** * Express Middleware: Request-Body bereinigen */ function sanitizeMiddleware(req, res, next) { if (req.body && typeof req.body === 'object') { req.body = sanitizeObject(req.body); } if (req.query && typeof req.query === 'object') { req.query = sanitizeObject(req.query); } if (req.params && typeof req.params === 'object') { req.params = sanitizeObject(req.params); } next(); } /** * Kontakt-Validierung Middleware */ validators.contact = function(req, res, next) { const errors = []; const { firstName, lastName, company, email, phone, mobile, website } = req.body; // Mindestens ein Name oder Firma muss vorhanden sein if (!firstName && !lastName && !company) { errors.push('Mindestens Vorname, Nachname oder Firma muss angegeben werden'); } // Email validieren if (email) { const emailError = validators.email(email, 'E-Mail'); if (emailError) errors.push(emailError); } // Website URL validieren if (website) { const urlError = validators.url(website, 'Website'); if (urlError) errors.push(urlError); } // Telefonnummer Format (optional) if (phone && !/^[\d\s\-\+\(\)]+$/.test(phone)) { errors.push('Telefonnummer enthält ungültige Zeichen'); } if (mobile && !/^[\d\s\-\+\(\)]+$/.test(mobile)) { errors.push('Mobilnummer enthält ungültige Zeichen'); } if (errors.length > 0) { return res.status(400).json({ errors }); } next(); }; module.exports = { stripHtml, sanitizeMarkdown, sanitizeObject, validators, validatePassword, sanitizeMiddleware };