Sicherheitscheck: Schwachstellen behoben

- Pfad-Traversal: safeFilename/containedPath-Helper, storeAnhang sanitizes
  filename, alle Download/Delete/Mail-Send-Routen pruefen Containment
- Stored XSS: serveInline entscheidet Viewable-Typ nur nach Extension,
  nicht nach client/seitigem MIME; nicht viewbare Typen werden als Download
  erzwungen. Upload fileFilter (Basis/Interne) + Extension-Validierung
  (Signatur/Foto leiten Ext aus MIME, blockieren .html)
- URL-Scheme-Allowlist (safeUrl) fuer quelle_url-hrefs gegen javascript:-XSS
- E-Mail-Iframe: Sandbox auf allow-same-only (kein allow-popups-to-escape)
- Sicherheits-Header: CSP, X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy, COOP; x-powered-by aus; jsPDF self-hosted unter /vendor
- Session: Secure-Flag bei TLS, serverseitige absoluteexpiry, Scrypt async
  + Dummy-Verify gegen Timing/Enumerate + Login-Rate-Limit
- Open Redirect: /email/fetch nur same-origin Redirects
- SSRF: Validierung von OLLAMA_HOST/CALDAV_URL/MAIL_HOST gegen
  Metadata/Link-Local-BLock (localhost/LAN bleibt erlaubt)
- Globaler Error-Handler ohne Interna-Leak, env=production

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 02:09:17 +02:00
co-authored by Claude
parent da0b497972
commit fd1db3970f
10 changed files with 751 additions and 76 deletions
+24 -15
View File
@@ -3,10 +3,17 @@
// No external dependency (bcrypt would need a native build step). scrypt is
// memory-hard and well suited for interactive logins. Hash format:
// "<saltHex>:<hashHex>" (salt is 16 bytes, hash is 64 bytes)
//
// verify() is async (crypto.scrypt, non-blocking) so a login attempt cannot pin
// the event loop and stall every other request — a synchronous scrypt was a
// cheap CPU-DoS vector. DUMMY_HASH is a real hash for the "unknown user" path so
// login timing does not leak which usernames exist.
const crypto = require('crypto');
const KEYLEN = 64;
const DUMMY_SALT = '00000000000000000000000000000000';
const DUMMY_HASH = `${DUMMY_SALT}:${crypto.scryptSync('nextjobs-dummy', DUMMY_SALT, KEYLEN).toString('hex')}`;
function hash(password) {
const salt = crypto.randomBytes(16).toString('hex');
@@ -14,21 +21,23 @@ function hash(password) {
return `${salt}:${out}`;
}
// Resolve to true/false. Always runs a full scrypt (constant work) before
// deciding, so the caller cannot tell a wrong password from a bad hash format.
function verify(password, stored) {
if (typeof stored !== 'string' || !stored.includes(':')) return false;
const idx = stored.indexOf(':');
const salt = stored.slice(0, idx);
const expected = stored.slice(idx + 1);
if (!salt || !expected) return false;
let computed;
try {
computed = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
} catch (e) {
return false;
}
if (computed.length !== expected.length) return false;
// Constant-time compare to avoid timing side channels.
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
return new Promise((resolve) => {
if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false);
const idx = stored.indexOf(':');
const salt = stored.slice(0, idx);
const expected = stored.slice(idx + 1);
if (!salt || !expected) return resolve(false);
crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => {
if (err) return resolve(false);
const computed = buf.toString('hex');
if (computed.length !== expected.length) return resolve(false);
// Constant-time compare to avoid timing side channels.
resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)));
});
});
}
module.exports = { hash, verify };
module.exports = { hash, verify, DUMMY_HASH };