diff --git a/.logins.example b/.logins.example deleted file mode 100644 index 884e0fc..0000000 --- a/.logins.example +++ /dev/null @@ -1,8 +0,0 @@ -# This file stores user logins (and this file only) -# There is no other way to add user logins -# Comments in this file may only start at the very beginning of a line - -# password is bcrypt of 123456 -# the format per line is ;; -foo@example.com;;$2a$12$JchPr84/tmKH2muqomK1qe/cj/X0PwcooA5ugynNn3HjU/wpxoNEe - diff --git a/docker-compose.yml b/docker-compose.yml index 309f7cd..c2b5e30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,7 @@ services: - LOGIN_FILE=/app/.logins - UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS} - MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH} + - TRUST_PROXY=true - PORT=3000 volumes: diff --git a/expressjs/src/server.js b/expressjs/src/server.js index 08975ff..287d873 100644 --- a/expressjs/src/server.js +++ b/expressjs/src/server.js @@ -17,7 +17,6 @@ const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/m const port = parseInt(process.env.PORT || '3000', 10); const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite'); -const loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins'); const adminHash = process.env.MANAGEMENT_ADMIN_HASH || ''; const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10); const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); @@ -41,6 +40,10 @@ app.set('trust proxy', trustProxy); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); app.use(express.json()); +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); +}); const db = new sqlite3.Database(dbPath); @@ -66,6 +69,11 @@ db.serialize(() => { )`); db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)'); db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)'); + db.run(`CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + )`); }); function run(sql, params = []) { @@ -112,6 +120,13 @@ function logEvent(event, owner, detail) { ).catch(() => undefined); } +const csrfCookieName = 'csrf'; +const csrfCookieOptions = { + httpOnly: true, + sameSite: 'strict', + secure: process.env.COOKIE_SECURE === 'true', +}; + function escapeHtml(value) { return String(value) .replace(/&/g, '&') @@ -121,6 +136,60 @@ function escapeHtml(value) { .replace(/'/g, '''); } +function csrfField(token) { + return ``; +} + +function ensureCsrfToken(req, res, next) { + let token = req.cookies[csrfCookieName]; + if (!token) { + token = crypto.randomBytes(32).toString('hex'); + res.cookie(csrfCookieName, token, csrfCookieOptions); + } + res.locals.csrfToken = token; + next(); +} + +function isSameOrigin(req) { + const origin = req.get('origin'); + const referer = req.get('referer'); + const header = origin || referer; + if (!header) { + return false; + } + try { + const parsed = new URL(header); + const expected = `${req.protocol}://${req.get('host')}`; + return parsed.origin === expected; + } catch (err) { + return false; + } +} + +function csrfGuard(req, res, next) { + if (!isSameOrigin(req)) { + if (req.path.startsWith(`${basePath}/api/`)) { + res.status(403).json({ error: 'Origin check failed' }); + return; + } + res.status(403).send(renderPage('Zugriff verweigert', '

Origin-Prüfung fehlgeschlagen.

')); + return; + } + + const token = req.cookies[csrfCookieName]; + const provided = req.body?.csrfToken || req.get('x-csrf-token'); + if (!token || !provided || token !== provided) { + if (req.path.startsWith(`${basePath}/api/`)) { + res.status(403).json({ error: 'CSRF token mismatch' }); + return; + } + res.status(403).send(renderPage('Zugriff verweigert', '

CSRF-Prüfung fehlgeschlagen.

')); + return; + } + + next(); +} + const loginAttempts = new Map(); const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_MAX_ATTEMPTS = 10; @@ -152,35 +221,9 @@ function clearLoginAttempts(type, req) { loginAttempts.delete(`${type}:${ip}`); } -function parseLogins(contents) { - const entries = new Map(); - const lines = contents.split(/\r?\n/); - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - const parts = line.split(';;'); - if (parts.length !== 2) { - continue; - } - const username = parts[0].trim(); - const hash = parts[1].trim(); - if (!username || !hash) { - continue; - } - entries.set(username, hash); - } - return entries; -} - -async function loadLogins() { - try { - const contents = await fs.promises.readFile(loginFile, 'utf8'); - return parseLogins(contents); - } catch (err) { - return new Map(); - } +async function getUserHash(username) { + const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]); + return row ? row.password_hash : null; } function toBase32(buffer) { @@ -274,31 +317,34 @@ function renderFileManagerPage(title, body) { ${title} @@ -438,6 +484,15 @@ async function cleanupExpired() { } } +app.use(ensureCsrfToken); +app.use((req, res, next) => { + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + csrfGuard(req, res, next); + return; + } + next(); +}); + const cleanupTimer = setInterval(() => { cleanupExpired().catch(() => undefined); }, 60 * 1000); @@ -467,6 +522,7 @@ app.get(`${basePath}/login`, (req, res) => {
+ ${csrfField(res.locals.csrfToken)}