Security & UX Release v7
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
This commit is contained in:
@@ -9,7 +9,17 @@ const db = require('../db');
|
||||
const { auditLog } = require('../auditLog');
|
||||
|
||||
// P6: Cookie config - defined early for use in CSRF and auth cookies
|
||||
// M1: Cookie security is now configurable via COOKIE_SECURE env var so that
|
||||
// plain-HTTP deployments (e.g. behind a TLS-terminating proxy that sets
|
||||
// X-Forwarded-Proto) can still use secure cookies, while HTTP-only dev/test
|
||||
// setups can disable them. Defaults to NODE_ENV === 'production'.
|
||||
// COOKIE_SECURE=true → always secure
|
||||
// COOKIE_SECURE=false → never secure (HTTP dev)
|
||||
// unset → secure in production, lax in development
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: isProduction;
|
||||
const COOKIE_NAME = 'workflow_token';
|
||||
|
||||
function hashToken(token) {
|
||||
@@ -24,7 +34,18 @@ const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||
// Advantage: cookie and header can never drift apart (no more stale-token 403s),
|
||||
// works across tabs, page reloads and re-logins. The cookie is self-healed by
|
||||
// authMiddleware on every request if it is missing or out of sync.
|
||||
const CSRF_SECRET = process.env.SESSION_SECRET || 'workflow-portal-csrf-v1';
|
||||
// M2: Fail-fast in production if SESSION_SECRET is missing — a hardcoded
|
||||
// fallback secret in the source tree is a security risk.
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || '';
|
||||
if (!SESSION_SECRET) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('[FATAL] SESSION_SECRET Umgebungsvariable ist in der Produktion nicht gesetzt. Setze sie auf einen langen, zufälligen Wert.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.warn('[WARN] SESSION_SECRET nicht gesetzt — verwende unsicheren Fallback nur für die Entwicklung.');
|
||||
}
|
||||
}
|
||||
const CSRF_SECRET = SESSION_SECRET || 'workflow-portal-csrf-dev-only-fallback';
|
||||
|
||||
function deriveCSRFToken(tokenHash) {
|
||||
return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex');
|
||||
@@ -36,7 +57,7 @@ function setCSRFCookie(res, tokenHash) {
|
||||
const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : 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,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
|
||||
path: '/',
|
||||
@@ -65,7 +86,7 @@ function csrfMiddleware(req, res, next) {
|
||||
if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) {
|
||||
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -99,7 +120,7 @@ async function authMiddleware(req, res, next) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -141,12 +162,12 @@ async function createSession(userId, oldRawToken) {
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
async function deleteSession(rawToken) {
|
||||
async function deleteSession(rawToken, req) {
|
||||
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);
|
||||
auditLog(session.user_id, 'logout', 'user', session.user_id, null, req);
|
||||
}
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
||||
}
|
||||
@@ -190,7 +211,7 @@ function setAuthCookie(res, token) {
|
||||
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: ttlHours * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -204,6 +225,6 @@ function clearAuthCookie(res) {
|
||||
module.exports = {
|
||||
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
|
||||
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
|
||||
setAuthCookie, clearAuthCookie, COOKIE_NAME,
|
||||
setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE,
|
||||
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
|
||||
};
|
||||
@@ -23,6 +23,16 @@ const loginLimiter = rateLimit({
|
||||
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
|
||||
});
|
||||
|
||||
// H5: Register rate limit: 10 registrations per hour per IP (prevents account
|
||||
// flooding, audit-log spam, and bcrypt CPU abuse)
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Registrierungsversuche. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
// Punkt 23: Task creation rate limit: 20 per minute per user
|
||||
const taskCreateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@@ -43,4 +53,4 @@ const uploadLimiter = rateLimit({
|
||||
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter };
|
||||
module.exports = { apiLimiter, loginLimiter, registerLimiter, taskCreateLimiter, uploadLimiter };
|
||||
@@ -77,17 +77,35 @@ const updateTemplateSchema = z.object({
|
||||
});
|
||||
|
||||
// ============ 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: z.string().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: z.string().optional(),
|
||||
file_path: filePathSchema.optional(),
|
||||
})).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
|
||||
});
|
||||
|
||||
@@ -206,4 +224,5 @@ module.exports = {
|
||||
// Middleware
|
||||
validate,
|
||||
validateQuery,
|
||||
isSafeUploadPath,
|
||||
};
|
||||
Reference in New Issue
Block a user