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:
Kühn
2026-08-24 09:45:28 +02:00
commit 6be1791c62
103 changed files with 12253 additions and 0 deletions

202
backend/migrations.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* Database initialization and migrations module.
*
* Punkt 4: Supports both SQLite and PostgreSQL.
* Migrations are tracked in a _migrations table to avoid re-running.
*
* Note: SQLite mode is synchronous, PostgreSQL mode is async.
* The initDatabase function handles both cases.
*/
const db = require('./db');
const isPostgres = db._type === 'postgres';
// Helper: convert SQLite SQL to PostgreSQL-compatible SQL
function toPg(sql) {
return sql
.replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY')
.replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()')
.replace(/`/g, '"');
}
function execSql(sql) {
if (isPostgres) {
return db.exec(toPg(sql));
}
return db.exec(sql);
}
async function initDatabase() {
// Create migrations tracking table
const migrationsTableSql = isPostgres
? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())`
: `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
await execSql(migrationsTableSql);
const appliedRows = await db.prepare('SELECT name FROM _migrations').all();
const applied = new Set(appliedRows.map(r => r.name));
async function migrate(name, sql) {
if (applied.has(name)) return;
console.log(`[Migration] ${name}...`);
await execSql(sql);
await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
console.log(`[Migration] ${name} done.`);
}
// Base schema
const baseSchema = isPostgres ? `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local',
username TEXT,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
` : `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local',
username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
`;
await execSql(baseSchema);
// Indexes
const indexes = [
'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires',
'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status',
'idx_task_values_task_id', 'idx_task_values_step_id',
'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source',
];
for (const idx of indexes) {
const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token');
// Build CREATE INDEX statement
let col;
if (idx === 'idx_sessions_token') col = 'sessions(token)';
else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)';
else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)';
else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)';
else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)';
else if (idx === 'idx_tasks_status') col = 'tasks(status)';
else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)';
else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)';
else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)';
else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)';
else if (idx === 'idx_users_email') col = 'users(email)';
else if (idx === 'idx_users_source') col = 'users(source)';
await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`);
}
// Seed admin user
const bcrypt = require('bcryptjs');
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local';
const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || '';
const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
if (!existingAdmin) {
if (!ADMIN_INIT_PASSWORD) {
console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.');
} else {
const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12);
await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin');
console.log('Superadmin erstellt: ' + ADMIN_EMAIL);
}
}
// Periodic session cleanup
setInterval(async () => {
try {
const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')";
await db.prepare(cleanupSql).run();
} catch (err) {
console.error('Session cleanup error:', err.message);
}
}, 60 * 60 * 1000);
console.log('Datenbanktabellen initialisiert.');
}
module.exports = { initDatabase };