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

View File

@ -1,3 +1,12 @@
# Ports, auf denen die Services vom Host erreichbar sind. # Ports, auf denen die Services vom Host erreichbar sind.
APP_PORT=3500 APP_PORT=3500
WEBDAV_PORT=1900 WEBDAV_PORT=1900
# Auf "true" setzen, wenn die App hinter einem Reverse-Proxy (nginx/Traefik/Caddy)
# läuft, der X-Forwarded-For/-Proto setzt. Sonst bitte aus lassen, sonst können
# Angreifer per gespooften X-Forwarded-For die Rate-Limits umgehen.
# TRUST_PROXY=true
# Erzwingt das "Secure"-Flag auf Session-Cookies (für HTTPS-only Setups).
# Wenn TRUST_PROXY=true gesetzt ist, wird das automatisch erkannt.
# SECURE_COOKIES=true

View File

@ -66,8 +66,41 @@ Der WebDAV-Container (Debian Apache) beobachtet das Verzeichnis via `inotifywait
## WebDAV-Zugriff ## WebDAV-Zugriff
- macOS Finder: `Gehe zu → Mit Server verbinden → http://HOST:1900/` - **macOS Finder**: `Gehe zu → Mit Server verbinden → http://HOST:1900/`
- Windows: Netzlaufwerk hinzufügen → `http://HOST:1900/` - **Linux / KDE Dolphin**: `webdav://<user>@HOST:1900/`
- Linux / KDE Dolphin: `webdav://<user>@HOST:1900/` - **Windows**: siehe Abschnitt unten (einmalige Einrichtung nötig)
- Write-Rechte umfassen: `PUT`, `DELETE`, `MKCOL`, `MOVE`, `COPY`, `PROPPATCH`, `LOCK`, `UNLOCK`.
- In Dolphin löscht **Shift+Entf** direkt (umgeht den nicht existierenden WebDAV-Papierkorb). Write-Rechte umfassen: `PUT`, `DELETE`, `MKCOL`, `MOVE`, `COPY`, `PROPPATCH`, `LOCK`, `UNLOCK`.
In Dolphin löscht **Shift+Entf** direkt (umgeht den nicht existierenden WebDAV-Papierkorb).
### Windows als Netzlaufwerk verbinden
Windows verweigert standardmäßig WebDAV via Basic Auth über HTTP und limitiert
Dateien auf 50 MB. Beides per Registry erlauben:
1. [windows/enable-webdav-basicauth.reg](windows/enable-webdav-basicauth.reg)
per Rechtsklick → **Zusammenführen** (als Administrator) ausführen.
2. WebClient-Dienst (neu) starten:
```cmd
sc config WebClient start=auto
net stop WebClient
net start WebClient
```
3. Verbinden — **HTTP-URL ist die saubere Variante** (eindeutig WebDAV, keine
SMB-Verwechslung). Beispiel mit `karlheinz`-Credentials, gemountet als `Z:`:
```cmd
net use Z: http://HOST:1900/frank-meier /user:karlheinz DEINPW /persistent:yes
```
Im Explorer **„Netzlaufwerk verbinden"** → Adresse:
`http://HOST:1900/` bzw. `http://HOST:1900/frank-meier/`.
Alternativ mit UNC-Syntax (intern dasselbe, etwas hakeliger):
`\\HOST@1900\DavWWWRoot\frank-meier\``@1900` und `\DavWWWRoot\` sind
dabei Pflicht, sonst landest du bei SMB Port 445.
Wenn weiterhin „Ordner nicht gefunden" → meist ist der WebClient-Dienst nicht
gestartet oder das Registry-Merge wurde nicht als Administrator ausgeführt.

View File

@ -11,7 +11,9 @@
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"multer": "^1.4.5-lts.1", "express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"multer": "^2.0.0",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"nodemailer": "^6.9.15" "nodemailer": "^6.9.15"
} }

View File

@ -160,6 +160,7 @@
.pill.archived { color: var(--text-dim); border-color: var(--border-strong); .pill.archived { color: var(--text-dim); border-color: var(--border-strong);
background: color-mix(in srgb, var(--text-dim) 10%, transparent); } background: color-mix(in srgb, var(--text-dim) 10%, transparent); }
tr.archived td:not(.cell-actions) { opacity: .55; } tr.archived td:not(.cell-actions) { opacity: .55; }
select.role-select { padding: .25rem .5rem; font-size: .85rem; min-width: 9rem; }
.small { font-size: .8rem; color: var(--text-muted); } .small { font-size: .8rem; color: var(--text-muted); }
.muted { color: var(--text-muted); } .muted { color: var(--text-muted); }
@ -220,8 +221,8 @@
<form id="setupForm"> <form id="setupForm">
<div class="field"><label>Benutzername</label> <div class="field"><label>Benutzername</label>
<input name="username" required pattern="[A-Za-z0-9._-]+" autocomplete="username" /></div> <input name="username" required pattern="[A-Za-z0-9._-]+" autocomplete="username" /></div>
<div class="field"><label>Passwort (min. 6 Zeichen)</label> <div class="field"><label>Passwort (min. 8 Zeichen)</label>
<input name="password" type="password" required minlength="6" autocomplete="new-password" /></div> <input name="password" type="password" required minlength="8" autocomplete="new-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Admin anlegen & einloggen</button></div> <div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Admin anlegen & einloggen</button></div>
<div class="err" id="setupErr"></div> <div class="err" id="setupErr"></div>
</form> </form>
@ -253,6 +254,7 @@
</div> </div>
<div class="nav-right"> <div class="nav-right">
<span id="whoami"></span> <span id="whoami"></span>
<button id="changePwBtn" class="btn sm">Passwort ändern</button>
<button id="logoutBtn" class="btn sm">Logout</button> <button id="logoutBtn" class="btn sm">Logout</button>
</div> </div>
</nav> </nav>
@ -299,7 +301,7 @@
<h3 class="card-title">Benutzer anlegen</h3> <h3 class="card-title">Benutzer anlegen</h3>
<form id="createUserForm" class="form-row"> <form id="createUserForm" class="form-row">
<div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div> <div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div>
<div><label>Passwort</label><input name="password" required minlength="6" type="text" /></div> <div><label>Passwort</label><input name="password" required minlength="8" type="text" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div> <div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div>
<div><label>Rolle</label> <div><label>Rolle</label>
<select name="role"> <select name="role">
@ -332,10 +334,10 @@
<div class="placeholder">kein Logo</div> <div class="placeholder">kein Logo</div>
</div> </div>
<div style="flex:1"> <div style="flex:1">
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif" style="display:none" /> <input type="file" id="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none" />
<button class="btn" id="logoUploadBtn" type="button">Logo hochladen</button> <button class="btn" id="logoUploadBtn" type="button">Logo hochladen</button>
<button class="btn danger" id="logoDeleteBtn" type="button" style="margin-left:.25rem">Entfernen</button> <button class="btn danger" id="logoDeleteBtn" type="button" style="margin-left:.25rem">Entfernen</button>
<div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, SVG, WEBP, GIF.</div> <div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, WEBP, GIF.</div>
</div> </div>
</div> </div>
<div class="field"><label>Breite (px) — 0 = automatisch</label> <div class="field"><label>Breite (px) — 0 = automatisch</label>
@ -413,6 +415,36 @@
</div> </div>
</div> </div>
<!-- EDIT CUSTOMER MODAL -->
<div class="modal" id="editCustomerModal">
<div class="modal-inner">
<h3 id="editCustomerTitle">Kunde bearbeiten</h3>
<form id="editCustomerForm">
<div class="field"><label>E-Mail</label>
<input name="email" type="email" placeholder="leer = entfernen" /></div>
<div class="field"><label>Passwort (leer = unverändert, min. 8 Zeichen)</label>
<input name="password" type="text" minlength="8" autocomplete="new-password" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_password" style="width:auto" />
Vorhandenes Passwort entfernen
</label>
<div class="field" style="margin-top:1rem"><label>Ablaufdatum</label>
<input name="expires_at" type="datetime-local" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_expires" style="width:auto" />
Ablauf entfernen (unbegrenzt gültig)
</label>
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
<button type="button" class="btn" id="editCustomerCancel">Abbrechen</button>
<button type="submit" class="btn primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- ACCESS MODAL --> <!-- ACCESS MODAL -->
<div class="modal" id="accessModal"> <div class="modal" id="accessModal">
<div class="modal-inner"> <div class="modal-inner">
@ -527,6 +559,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; } catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
}); });
document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); }; document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); };
document.getElementById('changePwBtn').onclick = async () => {
const oldPw = prompt('Aktuelles Passwort:');
if (!oldPw) return;
const newPw = prompt('Neues Passwort (min. 8 Zeichen):');
if (!newPw) return;
if (newPw.length < 8) return alert('Passwort zu kurz.');
const repeat = prompt('Neues Passwort wiederholen:');
if (newPw !== repeat) return alert('Eingaben stimmen nicht überein.');
try {
await api.send('POST', '/me/password', { old_password: oldPw, new_password: newPw });
alert('Passwort geändert. Andere Sitzungen wurden abgemeldet.');
} catch (ex) { alert('Fehler: ' + ex.message); }
};
// Tabs // Tabs
document.querySelectorAll('.tab').forEach(btn => { document.querySelectorAll('.tab').forEach(btn => {
@ -543,8 +588,10 @@ document.querySelectorAll('.tab').forEach(btn => {
}); });
// Customers // Customers
let customersCache = [];
async function loadCustomers() { async function loadCustomers() {
const data = await api.get('/customers'); const data = await api.get('/customers');
customersCache = data;
const rows = document.getElementById('customerRows'); const rows = document.getElementById('customerRows');
const adminOnly = me.role === 'admin'; const adminOnly = me.role === 'admin';
rows.innerHTML = ''; rows.innerHTML = '';
@ -610,14 +657,8 @@ document.getElementById('customerRows').addEventListener('click', async (e) => {
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers(); await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers();
} }
if (t.dataset.edit) { if (t.dataset.edit) {
const em = prompt('E-Mail (leer = unverändert, "-" = entfernen):', ''); if (em === null) return; const c = customersCache.find(x => String(x.id) === t.dataset.edit);
const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', ''); if (pw === null) return; if (c) openEditCustomer(c);
const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return;
const body = {};
if (em === '-') body.email = ''; else if (em) body.email = em;
if (pw === '-') body.clear_password = true; else if (pw) body.password = pw;
if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp);
await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers();
} }
if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name); if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name);
}); });
@ -642,15 +683,19 @@ async function loadUsers() {
rows.innerHTML = ''; rows.innerHTML = '';
for (const u of data) { for (const u of data) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const roleSel = `
<select data-rolesel="${u.id}" data-prev="${u.role}" class="role-select">
<option value="staff" ${u.role === 'staff' ? 'selected' : ''}>Sachbearbeiter</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>`;
tr.innerHTML = ` tr.innerHTML = `
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td> <td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td class="small">${u.email ? esc(u.email) : ''}</td> <td class="small">${u.email ? esc(u.email) : ''}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></td> <td>${roleSel}</td>
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td> <td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
<td class="cell-actions"> <td class="cell-actions">
<button class="btn sm" data-email="${u.id}">E-Mail</button> <button class="btn sm" data-email="${u.id}">E-Mail</button>
<button class="btn sm" data-pw="${u.id}">Passwort</button> <button class="btn sm" data-pw="${u.id}">Passwort</button>
<button class="btn sm" data-role="${u.id}" data-current="${u.role}">Rolle</button>
<button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button> <button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button>
</td>`; </td>`;
rows.appendChild(tr); rows.appendChild(tr);
@ -665,19 +710,32 @@ document.getElementById('userRows').addEventListener('click', async (e) => {
await api.send('PATCH', `/users/${t.dataset.email}`, { email: em || '' }); loadUsers(); await api.send('PATCH', `/users/${t.dataset.email}`, { email: em || '' }); loadUsers();
} }
if (t.dataset.pw) { if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return; const pw = prompt('Neues Passwort (min. 8 Zeichen):', ''); if (!pw) return;
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers(); await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers();
} }
if (t.dataset.role) {
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); loadUsers();
}
if (t.dataset.deluser && confirm(`Benutzer "${t.dataset.name}" löschen?`)) { if (t.dataset.deluser && confirm(`Benutzer "${t.dataset.name}" löschen?`)) {
await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers(); await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers();
} }
} catch (ex) { alert('Fehler: ' + ex.message); } } catch (ex) { alert('Fehler: ' + ex.message); }
}); });
document.getElementById('userRows').addEventListener('change', async (e) => {
const sel = e.target;
if (!sel.dataset || !sel.dataset.rolesel) return;
const newRole = sel.value;
const prev = sel.dataset.prev;
if (newRole === prev) return;
if (!confirm(`Rolle wirklich auf "${newRole}" ändern?`)) {
sel.value = prev;
return;
}
try {
await api.send('PATCH', `/users/${sel.dataset.rolesel}`, { role: newRole });
sel.dataset.prev = newRole;
} catch (ex) {
alert('Fehler: ' + ex.message);
sel.value = prev;
}
});
document.getElementById('createUserForm').addEventListener('submit', async (e) => { document.getElementById('createUserForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
try { await api.send('POST', '/users', Object.fromEntries(new FormData(e.target))); e.target.reset(); loadUsers(); } try { await api.send('POST', '/users', Object.fromEntries(new FormData(e.target))); e.target.reset(); loadUsers(); }
@ -829,6 +887,41 @@ document.getElementById('logoDeleteBtn').onclick = async () => {
updateLogoPreview(''); updateLogoPreview('');
}; };
// Edit Customer modal
let editingCustomerId = null;
function dtLocal(ms) {
const d = new Date(ms);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function openEditCustomer(c) {
editingCustomerId = c.id;
document.getElementById('editCustomerTitle').textContent = `Bearbeiten ${c.name}`;
const f = document.getElementById('editCustomerForm');
f.email.value = c.email || '';
f.password.value = '';
f.clear_password.checked = false;
f.clear_expires.checked = false;
f.expires_at.value = c.expires_at ? dtLocal(c.expires_at) : '';
document.getElementById('editCustomerModal').classList.add('open');
}
document.getElementById('editCustomerCancel').onclick = () =>
document.getElementById('editCustomerModal').classList.remove('open');
document.getElementById('editCustomerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = { email: (fd.get('email') || '').trim() };
if (fd.get('clear_password')) body.clear_password = true;
else if (fd.get('password')) body.password = fd.get('password');
if (fd.get('clear_expires')) body.expires_at = null;
else if (fd.get('expires_at')) body.expires_at = new Date(fd.get('expires_at')).getTime();
try {
await api.send('PATCH', `/customers/${editingCustomerId}`, body);
document.getElementById('editCustomerModal').classList.remove('open');
loadCustomers();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
// Access modal // Access modal
let accessCustomerId = null; let accessCustomerId = null;
async function openAccessModal(id, name) { async function openAccessModal(id, name) {

View File

@ -5,6 +5,11 @@ const db = require('./db');
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
const COOKIE_NAME = 'sfu_session'; const COOKIE_NAME = 'sfu_session';
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/i; 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) { function parseCookies(req) {
const raw = req.headers.cookie || ''; const raw = req.headers.cookie || '';
@ -30,7 +35,7 @@ function validateUsername(u) {
} }
function validatePassword(p) { 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') { async function createUser(username, password, role = 'staff') {
@ -52,9 +57,10 @@ async function setUserPassword(id, password) {
async function verifyCredentials(username, password) { async function verifyCredentials(username, password) {
const u = db.prepare('SELECT * FROM users WHERE username = ?').get((username || '').toLowerCase()); const u = db.prepare('SELECT * FROM users WHERE username = ?').get((username || '').toLowerCase());
if (!u) return null; // Always run bcrypt so response time does not reveal whether the user exists.
const ok = await bcrypt.compare(password || '', u.password_hash); const hash = u ? u.password_hash : DUMMY_HASH;
return ok ? u : null; const ok = await bcrypt.compare(password || '', hash);
return (u && ok) ? u : null;
} }
function createSession(user_id) { function createSession(user_id) {
@ -82,9 +88,14 @@ function deleteSession(token) {
} }
function setSessionCookie(res, 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, { res.cookie(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure,
maxAge: SESSION_TTL_MS, maxAge: SESSION_TTL_MS,
path: '/', path: '/',
}); });
@ -116,6 +127,7 @@ function cleanupExpiredSessions() {
module.exports = { module.exports = {
COOKIE_NAME, COOKIE_NAME,
SESSION_TTL_MS, SESSION_TTL_MS,
MIN_PASSWORD,
parseCookies, parseCookies,
hasAnyUser, hasAnyUser,
validateUsername, validateUsername,

View File

@ -1,4 +1,6 @@
const express = require('express'); const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
@ -27,9 +29,41 @@ settings.seedFromEnv();
fs.mkdirSync(UPLOAD_ROOT, { recursive: true }); fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
const app = express(); 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.json());
app.use(express.urlencoded({ extended: true })); 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 ---------- // ---------- Helpers ----------
function slugify(name) { function slugify(name) {
return 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' }); if (auth.hasAnyUser()) return res.status(409).json({ error: 'already configured' });
const { username, password } = req.body || {}; const { username, password } = req.body || {};
if (!auth.validateUsername(username)) return res.status(400).json({ error: 'invalid username' }); 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 { username, password } = req.body || {};
const u = await auth.verifyCredentials(username, password); const u = await auth.verifyCredentials(username, password);
if (!u) return res.status(401).json({ error: 'invalid credentials' }); 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 }); 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) --- // --- Settings (admin only) ---
api.get('/settings', auth.requireAdmin, (req, res) => { api.get('/settings', auth.requireAdmin, (req, res) => {
res.json({ res.json({
@ -272,24 +322,26 @@ api.post('/settings/test-mail', auth.requireAdmin, async (req, res) => {
}); });
// --- Logo (admin manages, public serves) --- // --- 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({ const logoUpload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 }, limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => { 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); cb(ok ? null : new Error('invalid image type'), ok);
}, },
}); });
api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => { api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' }); if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = { const ext = ALLOWED_LOGO_MIMES[req.file.mimetype] || '';
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'image/webp': '.webp',
}[req.file.mimetype] || '';
const oldFn = settings.get('logo_filename', ''); const oldFn = settings.get('logo_filename', '');
if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} } if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} }
const fn = `logo${ext}`; const fn = `logo${ext}`;
@ -479,7 +531,7 @@ app.get('/u/:token', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html')); 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); const c = getCustomerByToken(req.params.token);
if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' });
if (!c.password_hash) return res.json({ ok: true }); 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) => { app.get('/logo', (req, res) => {
const fn = settings.get('logo_filename', ''); const fn = settings.get('logo_filename', '');
if (!fn) return res.status(404).end(); 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(); if (!fs.existsSync(fp)) return res.status(404).end();
res.setHeader('Cache-Control', 'public, max-age=60'); res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(fp); res.sendFile(fp);
}); });

View File

@ -0,0 +1,12 @@
Windows Registry Editor Version 5.00
; Enables WebDAV via Basic Auth over plain HTTP and lifts the 50 MB
; file size limit so large uploads/downloads work over a mapped drive.
;
; Rechtsklick -> "Zusammenführen" (als Administrator), dann
; den WebClient-Dienst neu starten:
; net stop WebClient && net start WebClient
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters]
"BasicAuthLevel"=dword:00000002
"FileSizeLimitInBytes"=dword:ffffffff