356 Zeilen
8.9 KiB
JavaScript
356 Zeilen
8.9 KiB
JavaScript
/**
|
|
* 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
|
|
};
|