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:
+16
-4
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user