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:
parent
3f86fca578
commit
182ef04cc5
|
|
@ -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
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
20
src/auth.js
20
src/auth.js
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue