DEV1.0: Initial commit - Workflow Portal with security fixes
- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration - Frontend: React 18 + Vite + TailwindCSS/DaisyUI - Security fixes applied (2026-07 + 2026-08): - LDAP injection prevention, CSRF protection, HttpOnly cookies - Session hashing (SHA-256), account lockout, rate limiting - Input validation (zod), file upload security, CSP/HSTS headers - V3: express-rate-limit updated (ip-address SSRF fix) - V4: postcss updated (nanoid DoS fix) - V5: Rate-limit on /health endpoint - V6: Session rotation on login (session fixation prevention) - V9: Task values array limit (DoS prevention) - V10: Frontend XSS audit completed - Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
170
backend/middleware/auth.js
Normal file
170
backend/middleware/auth.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Auth middleware module (async).
|
||||
*
|
||||
* Session tokens are hashed with SHA-256 for security (Punkt 4).
|
||||
* All DB calls are async (Punkt 4: PostgreSQL compatibility).
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
const { auditLog } = require('../auditLog');
|
||||
|
||||
// P6: Cookie config - defined early for use in CSRF and auth cookies
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const COOKIE_NAME = 'workflow_token';
|
||||
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// P4: CSRF protection (Double-Submit-Cookie pattern)
|
||||
const CSRF_COOKIE_NAME = 'workflow_csrf';
|
||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||
|
||||
function setCSRFCookie(res) {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // Must be readable by JS to send back in header
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h
|
||||
path: '/',
|
||||
});
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
function csrfMiddleware(req, res, next) {
|
||||
// Only check state-changing methods
|
||||
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
if (!stateChanging.includes(req.method)) return next();
|
||||
|
||||
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
|
||||
const headerToken = req.headers[CSRF_HEADER_NAME];
|
||||
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function authMiddleware(req, res, next) {
|
||||
// Punkt 8: Token from HttpOnly-Cookie OR Authorization header
|
||||
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!rawToken) return res.status(401).json({ error: 'Nicht authentifiziert.' });
|
||||
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const session = await db.prepare('SELECT s.id, s.user_id, s.expires_at, u.email, u.name, u.role, u.status, u.source, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ?').get(tokenHash);
|
||||
|
||||
if (!session) return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
|
||||
if (session.status === 'inaktiv') {
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
||||
return res.status(401).json({ error: 'Konto deaktiviert.' });
|
||||
}
|
||||
if (session.expires_at && new Date(session.expires_at) < new Date()) {
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
||||
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
|
||||
}
|
||||
|
||||
req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username };
|
||||
req.tokenHash = tokenHash;
|
||||
next();
|
||||
}
|
||||
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin-Rechte erforderlich.' });
|
||||
next();
|
||||
}
|
||||
|
||||
async function createSession(userId, oldRawToken) {
|
||||
// V6: Session-Rotation - invalidate old session on new login (prevents session fixation)
|
||||
if (oldRawToken) {
|
||||
const oldHash = hashToken(oldRawToken);
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(oldHash);
|
||||
}
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
||||
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Punkt 9: Session-Limitierung - max sessions per user
|
||||
const maxSessions = parseInt(process.env.SESSION_MAX_PER_USER) || 5;
|
||||
const existingSessions = await db.prepare('SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at ASC').all(userId);
|
||||
if (existingSessions.length >= maxSessions) {
|
||||
const toDelete = existingSessions.slice(0, existingSessions.length - maxSessions + 1);
|
||||
const deleteIds = toDelete.map(s => s.id).filter(id => Number.isInteger(id));
|
||||
if (deleteIds.length > 0) {
|
||||
const placeholders = deleteIds.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...deleteIds);
|
||||
}
|
||||
}
|
||||
|
||||
await db.prepare('INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, tokenHash, expiresAt);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
async function deleteSession(rawToken) {
|
||||
if (!rawToken) return;
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
|
||||
if (session) {
|
||||
auditLog(session.user_id, 'logout', 'user', session.user_id, null);
|
||||
}
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
||||
}
|
||||
|
||||
async function invalidateUserSessions(userId) {
|
||||
await db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
// Punkt 12: Account-Lockout functions
|
||||
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 5;
|
||||
const LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES) || 15;
|
||||
|
||||
async function isAccountLocked(userId) {
|
||||
const user = await db.prepare('SELECT locked_until FROM users WHERE id = ?').get(userId);
|
||||
if (!user || !user.locked_until) return false;
|
||||
if (new Date(user.locked_until) > new Date()) return true;
|
||||
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function recordFailedLogin(userId) {
|
||||
if (!userId) return;
|
||||
const user = await db.prepare('SELECT failed_login_attempts FROM users WHERE id = ?').get(userId);
|
||||
if (!user) return;
|
||||
const attempts = (user.failed_login_attempts || 0) + 1;
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
|
||||
await db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ?').run(attempts, lockedUntil, userId);
|
||||
} else {
|
||||
await db.prepare('UPDATE users SET failed_login_attempts = ? WHERE id = ?').run(attempts, userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSuccessfulLogin(userId) {
|
||||
if (!userId) return;
|
||||
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
// Punkt 8: Cookie helpers
|
||||
function setAuthCookie(res, token) {
|
||||
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: ttlHours * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function clearAuthCookie(res) {
|
||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
|
||||
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
|
||||
setAuthCookie, clearAuthCookie, COOKIE_NAME,
|
||||
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
|
||||
};
|
||||
46
backend/middleware/rateLimit.js
Normal file
46
backend/middleware/rateLimit.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Rate limiting configuration module.
|
||||
*
|
||||
* Punkt 23: User-level rate limiting for critical endpoints.
|
||||
*/
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
// General API rate limit: 100 requests per minute per IP
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
// Login rate limit: 5 attempts per minute per IP (brute-force protection)
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
|
||||
});
|
||||
|
||||
// Punkt 23: Task creation rate limit: 20 per minute per user
|
||||
const taskCreateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => !req.user,
|
||||
message: { error: 'Zu viele Auftragsanfragen. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
// Punkt 23: File upload rate limit: 10 per minute per user
|
||||
const uploadLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => !req.user,
|
||||
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter };
|
||||
209
backend/middleware/validation.js
Normal file
209
backend/middleware/validation.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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 ============
|
||||
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: z.string().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: z.string().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,
|
||||
};
|
||||
Reference in New Issue
Block a user