Harden security, polish admin UI and document Windows WebDAV

- helmet, express-rate-limit (login/setup/customer-auth/me-password)
- Constant-time login (bcrypt always runs against a dummy hash on miss)
- Cookie secure flag follows req.protocol; trust proxy is env-gated to
  prevent X-Forwarded-For spoofing on direct exposure
- Drop SVG from accepted logo types (same-origin XSS) and resolve the
  served logo path against LOGO_DIR as defense in depth
- Self-service /me/password endpoint plus header button; bumps minimum
  password length to 8 across backend, prompts and edit modal
- Multer 1.x → 2.x for current security backports
- Customer edit modal replaces stacked prompts; user role is now an
  inline dropdown with a confirm-and-revert flow
- Windows .reg helper plus README section for Basic-Auth-over-HTTP and
  the http:// vs \\HOST@PORT\DavWWWRoot\ mapping syntax

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 12:53:13 +02:00
parent 3f86fca578
commit 182ef04cc5
7 changed files with 263 additions and 43 deletions
+16 -4
View File
@@ -5,6 +5,11 @@ const db = require('./db');
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
const COOKIE_NAME = 'sfu_session';
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/i;
const MIN_PASSWORD = 8;
// Pre-computed bcrypt hash of a random password — used to keep login response
// time roughly constant when the username does not exist (prevents timing
// attacks that enumerate usernames).
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password-' + nanoid(16), 10);
function parseCookies(req) {
const raw = req.headers.cookie || '';
@@ -30,7 +35,7 @@ function validateUsername(u) {
}
function validatePassword(p) {
return typeof p === 'string' && p.length >= 6;
return typeof p === 'string' && p.length >= MIN_PASSWORD;
}
async function createUser(username, password, role = 'staff') {
@@ -52,9 +57,10 @@ async function setUserPassword(id, password) {
async function verifyCredentials(username, password) {
const u = db.prepare('SELECT * FROM users WHERE username = ?').get((username || '').toLowerCase());
if (!u) return null;
const ok = await bcrypt.compare(password || '', u.password_hash);
return ok ? u : null;
// Always run bcrypt so response time does not reveal whether the user exists.
const hash = u ? u.password_hash : DUMMY_HASH;
const ok = await bcrypt.compare(password || '', hash);
return (u && ok) ? u : null;
}
function createSession(user_id) {
@@ -82,9 +88,14 @@ function deleteSession(token) {
}
function setSessionCookie(res, token) {
// Mark cookie secure when the request actually came over HTTPS (incl.
// X-Forwarded-Proto from a TLS-terminating proxy when "trust proxy" is set).
const secure = process.env.SECURE_COOKIES === 'true' ||
(res.req && res.req.protocol === 'https');
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure,
maxAge: SESSION_TTL_MS,
path: '/',
});
@@ -116,6 +127,7 @@ function cleanupExpiredSessions() {
module.exports = {
COOKIE_NAME,
SESSION_TTL_MS,
MIN_PASSWORD,
parseCookies,
hasAnyUser,
validateUsername,
+71 -12
View File
@@ -1,4 +1,6 @@
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
@@ -27,9 +29,41 @@ settings.seedFromEnv();
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
const app = express();
// Only trust X-Forwarded-* when explicitly told there's a proxy in front.
// Otherwise an attacker hitting the app directly could spoof the header
// and bypass IP-based rate limits.
if (process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
// Security headers. CSP is disabled because the admin and upload pages use
// inline <script>/<style>; XSS protection comes from explicit escaping
// (esc()/escapeHtml()) and from rejecting dangerous file types at upload time.
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'same-origin' },
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Brute-force protection on auth-relevant endpoints
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
const customerAuthLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
// ---------- Helpers ----------
function slugify(name) {
return name
@@ -111,7 +145,7 @@ publicApi.get('/status', (req, res) => {
});
});
publicApi.post('/setup', async (req, res) => {
publicApi.post('/setup', loginLimiter, async (req, res) => {
if (auth.hasAnyUser()) return res.status(409).json({ error: 'already configured' });
const { username, password } = req.body || {};
if (!auth.validateUsername(username)) return res.status(400).json({ error: 'invalid username' });
@@ -127,7 +161,7 @@ publicApi.post('/setup', async (req, res) => {
}
});
publicApi.post('/login', async (req, res) => {
publicApi.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body || {};
const u = await auth.verifyCredentials(username, password);
if (!u) return res.status(401).json({ error: 'invalid credentials' });
@@ -207,6 +241,22 @@ api.delete('/users/:id', auth.requireAdmin, (req, res) => {
res.json({ ok: true });
});
// Self-service: any logged-in user can change their own password
api.post('/me/password', loginLimiter, auth.requireAuth, async (req, res) => {
const { old_password, new_password } = req.body || {};
if (!auth.validatePassword(new_password)) {
return res.status(400).json({ error: `Passwort zu kurz (min. ${auth.MIN_PASSWORD} Zeichen)` });
}
const u = await auth.verifyCredentials(req.user.username, old_password);
if (!u) return res.status(401).json({ error: 'aktuelles Passwort falsch' });
await auth.setUserPassword(req.user.id, new_password);
// Invalidate all other sessions of this user
const currentToken = auth.parseCookies(req)[auth.COOKIE_NAME];
db.prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(req.user.id, currentToken);
webdavConfig.sync(); // password hash changed → htpasswd needs refresh
res.json({ ok: true });
});
// --- Settings (admin only) ---
api.get('/settings', auth.requireAdmin, (req, res) => {
res.json({
@@ -272,24 +322,26 @@ api.post('/settings/test-mail', auth.requireAdmin, async (req, res) => {
});
// --- Logo (admin manages, public serves) ---
// SVG is intentionally NOT accepted: rendered SVGs can execute embedded scripts
// when the user navigates directly to /logo, allowing same-origin XSS.
const ALLOWED_LOGO_MIMES = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
};
const logoUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ok = /^image\/(png|jpeg|gif|svg\+xml|webp)$/.test(file.mimetype);
const ok = !!ALLOWED_LOGO_MIMES[file.mimetype];
cb(ok ? null : new Error('invalid image type'), ok);
},
});
api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'image/webp': '.webp',
}[req.file.mimetype] || '';
const ext = ALLOWED_LOGO_MIMES[req.file.mimetype] || '';
const oldFn = settings.get('logo_filename', '');
if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} }
const fn = `logo${ext}`;
@@ -479,7 +531,7 @@ app.get('/u/:token', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
});
app.post('/u/:token/auth', async (req, res) => {
app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => {
const c = getCustomerByToken(req.params.token);
if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' });
if (!c.password_hash) return res.json({ ok: true });
@@ -554,9 +606,16 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
app.get('/logo', (req, res) => {
const fn = settings.get('logo_filename', '');
if (!fn) return res.status(404).end();
const fp = path.join(LOGO_DIR, fn);
// Defense-in-depth path traversal check (current upload code only writes
// logo.<ext>, but the DB value is treated as untrusted on read).
const baseResolved = path.resolve(LOGO_DIR);
const fp = path.resolve(LOGO_DIR, fn);
if (fp !== baseResolved && !fp.startsWith(baseResolved + path.sep)) {
return res.status(404).end();
}
if (!fs.existsSync(fp)) return res.status(404).end();
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(fp);
});