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:
@@ -3,6 +3,7 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns');
|
||||
const multer = require('multer');
|
||||
|
||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||
@@ -79,6 +80,55 @@ function deriveArt(url, provided) {
|
||||
return 'Firmenwebsite';
|
||||
}
|
||||
|
||||
// Trust the Caddy reverse proxy (TLS terminator) so req.secure / req.ip reflect
|
||||
// the real client connection — needed for the Secure cookie flag and rate limit.
|
||||
app.set('trust proxy', 1);
|
||||
// Don't advertise the framework version.
|
||||
app.disable('x-powered-by');
|
||||
// Express ships with helpful dev-mode error pages; in production it never leaks
|
||||
// stack traces. We run behind Docker, so default to production unless overridden.
|
||||
app.set('env', process.env.NODE_ENV || 'production');
|
||||
|
||||
// --- Security headers ----------------------------------------------------
|
||||
// No helmet dependency: a small middleware sets the headers that matter. The
|
||||
// CSP is intentionally permissive about *inline* scripts/styles (the views use a
|
||||
// lot of inline <script>/<style>) but strict about everything else — it blocks
|
||||
// external scripts, object/embed, framing of the app (clickjacking), base-uri
|
||||
// hijacks and cross-origin forms/connects. Registered before express.static so
|
||||
// static responses carry the headers too.
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
|
||||
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; '));
|
||||
next();
|
||||
});
|
||||
|
||||
// URL scheme allowlist for any value placed into an href/src in the views.
|
||||
// Returns the URL unchanged for http(s)/mailto or a relative path, else '#' so a
|
||||
// stored `javascript:` payload becomes an inert anchor (EJS escapes the result).
|
||||
app.locals.safeUrl = function (url) {
|
||||
const u = String(url == null ? '' : url).trim();
|
||||
if (!u) return '';
|
||||
// Protocol-relative URLs (//evil.com) would navigate off-site on click.
|
||||
if (u.startsWith('//') || u.startsWith('\\/')) return '#';
|
||||
// Anything that looks like scheme:url must be an allowed scheme.
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(u) && !/^(https?|mailto):/i.test(u)) return '#';
|
||||
return u;
|
||||
};
|
||||
|
||||
// Middleware
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||
@@ -150,6 +200,33 @@ function requireAuth(req, res, next) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
// Simple in-memory login throttling keyed by client IP. After 8 failed attempts
|
||||
// within 15 minutes the IP is locked out for 15 minutes, which blunts online
|
||||
// password guessing. Cleared on the next successful login. Process restart wipes
|
||||
// it (acceptable — it is a rate limiter, not a persistent record).
|
||||
const loginFails = new Map();
|
||||
const LOGIN_WINDOW = 15 * 60 * 1000;
|
||||
const LOGIN_MAX = 8;
|
||||
function clientIp(req) {
|
||||
return String((req.ip || (req.socket && req.socket.remoteAddress) || '?'));
|
||||
}
|
||||
function loginGate(req) {
|
||||
const rec = loginFails.get(clientIp(req));
|
||||
return !rec || !rec.lock || rec.lock <= Date.now();
|
||||
}
|
||||
function loginFail(req) {
|
||||
const ip = clientIp(req);
|
||||
const now = Date.now();
|
||||
let rec = loginFails.get(ip);
|
||||
if (!rec || now - rec.since > LOGIN_WINDOW) rec = { since: now, count: 0, lock: 0 };
|
||||
rec.count++;
|
||||
if (rec.count >= LOGIN_MAX) rec.lock = now + LOGIN_WINDOW;
|
||||
loginFails.set(ip, rec);
|
||||
}
|
||||
function loginOk(req) {
|
||||
loginFails.delete(clientIp(req));
|
||||
}
|
||||
|
||||
// Login page + form handler.
|
||||
app.get('/login', (req, res) => {
|
||||
if (req.user) return res.redirect('/');
|
||||
@@ -158,10 +235,15 @@ app.get('/login', (req, res) => {
|
||||
|
||||
app.post('/login', async (req, res) => {
|
||||
const { username, password: plain } = req.body || {};
|
||||
if (!loginGate(req)) {
|
||||
return res.status(429).render('login', { error: 'Zu viele Versuche. Bitte später erneut versuchen.', username: username || '' });
|
||||
}
|
||||
const user = await authenticate(username, plain);
|
||||
if (!user) {
|
||||
loginFail(req);
|
||||
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
|
||||
}
|
||||
loginOk(req);
|
||||
const token = await createSession(user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/');
|
||||
@@ -217,19 +299,29 @@ function userStorageDirForReq(base, req) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Serve a stored file with `Content-Disposition: inline` so the browser opens it
|
||||
// (PDF/image) in a tab instead of downloading. Falls back to the file extension
|
||||
// when no MIME type is stored.
|
||||
// Serve a stored file inline ONLY for a small allow-list of viewable types,
|
||||
// decided by the file *extension* (never a client-/sender-supplied MIME). Every
|
||||
// other type is forced to a download, so an uploaded .html / .svg / odd MIME can
|
||||
// never render as an active document in the app origin (stored XSS).
|
||||
const INLINE_TYPES = {
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
};
|
||||
function serveInline(res, filePath, filename, mime) {
|
||||
const type = mime || {
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
}[path.extname(filename || filePath).toLowerCase()];
|
||||
if (type) res.type(type);
|
||||
const safe = String(filename || 'datei').replace(/["\r\n]/g, '');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const ext = path.extname(filename || filePath).toLowerCase();
|
||||
const type = INLINE_TYPES[ext] || INLINE_TYPES[path.extname(filePath).toLowerCase()];
|
||||
const safe = safeFilename(filename).replace(/["\r\n]/g, '');
|
||||
if (type) {
|
||||
res.type(type);
|
||||
res.setHeader('Content-Disposition',
|
||||
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
// Not a viewable type → download as an attachment, never inline.
|
||||
res.setHeader('Content-Disposition',
|
||||
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||||
res.sendFile(filePath);
|
||||
`attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||||
res.download(filePath, safe);
|
||||
}
|
||||
|
||||
// --- E-mail display + reply helpers --------------------------------------
|
||||
@@ -292,6 +384,16 @@ if (!fs.existsSync(basisAnhaengeDir)) {
|
||||
}
|
||||
|
||||
// Multipart upload for those static attachments (stored under the user's subdir)
|
||||
// Reject uploads whose extension is not on the allow-list. The on-disk name is
|
||||
// already sanitised by the filename callback, but this stops a renamed .html /
|
||||
// .svg from being stored at all (defense-in-depth against stored XSS via inline
|
||||
// serving) and keeps the stored MIME trustworthy.
|
||||
const ATTACHMENT_FILTER = (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
|
||||
cb(ok ? null : new Error('Dateityp nicht erlaubt'), ok);
|
||||
};
|
||||
|
||||
const uploadBasisAnhang = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
||||
@@ -301,6 +403,7 @@ const uploadBasisAnhang = multer({
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||
fileFilter: ATTACHMENT_FILTER,
|
||||
}).single('datei');
|
||||
|
||||
// Directory for private attachments the user keeps alongside the internal
|
||||
@@ -319,6 +422,7 @@ const uploadInterneAnhang = multer({
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||
fileFilter: ATTACHMENT_FILTER,
|
||||
}).single('datei');
|
||||
|
||||
// Directory + uploader for the applicant's signature image (used in the letter)
|
||||
@@ -329,13 +433,17 @@ if (!fs.existsSync(signaturDir)) {
|
||||
const uploadSignatur = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)),
|
||||
// Derive the extension from the validated MIME, not the user-supplied name,
|
||||
// so a "x.png"-named .html can never land on disk as signatur_*.html.
|
||||
filename: (req, file, cb) => {
|
||||
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
||||
const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
|
||||
cb(null, `signatur_${Date.now()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
||||
fileFilter: (req, file, cb) => cb(null,
|
||||
/^image\/(png|jpe?g)$/.test(file.mimetype)
|
||||
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
|
||||
}).single('signatur');
|
||||
|
||||
// The single stored signature file, if any (in the current user's subdir).
|
||||
@@ -373,12 +481,14 @@ const uploadFoto = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
||||
const ext = /jpe?g/i.test(file.mimetype) ? '.jpg' : '.png';
|
||||
cb(null, `foto_${Date.now()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
||||
fileFilter: (req, file, cb) => cb(null,
|
||||
/^image\/(png|jpe?g)$/.test(file.mimetype)
|
||||
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
|
||||
}).single('foto');
|
||||
|
||||
// The single stored photo file, if any (in the current user's subdir).
|
||||
@@ -421,6 +531,92 @@ function sanitizeInput(input) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// --- File / path safety helpers -----------------------------------------
|
||||
// Strip a stored/display filename down to a single safe segment: no path
|
||||
// separators, no traversal, only [A-Za-z0-9._-]. multer already sanitises the
|
||||
// on-disk name, but display names (basis_anhaenge.dateiname) are stored raw and
|
||||
// were later re-used to build a path — which let a crafted name escape its dir.
|
||||
function safeFilename(name) {
|
||||
return String(name == null ? 'datei' : name)
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.replace(/^\.+/, '') // no leading dots
|
||||
.slice(0, 200) || 'datei';
|
||||
}
|
||||
|
||||
// Sanitise a *display* filename for the Content-Disposition header: keep
|
||||
// unicode (umlauts etc.) but strip control chars / quotes / backslashes that
|
||||
// could inject or break an HTTP header. Used for download names shown to the
|
||||
// user — never for building an on-disk path (use safeFilename / containedPath).
|
||||
function displayFilename(name) {
|
||||
return String(name == null ? 'datei' : name)
|
||||
.replace(/[\x00-\x1f"\r\n\\]/g, '_')
|
||||
.replace(/^\.+/, '')
|
||||
.slice(0, 200) || 'datei';
|
||||
}
|
||||
|
||||
// Resolve a stored relative path inside a user's base directory and assert the
|
||||
// result actually stays inside it (defense-in-depth against stored traversal).
|
||||
// Returns the absolute path, or null if it would escape `base`.
|
||||
function containedPath(base, rel) {
|
||||
const root = path.resolve(base);
|
||||
const abs = path.resolve(root, String(rel == null ? '' : rel));
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
// Only allow redirect targets that are same-origin relative paths, so a crafted
|
||||
// `back=https://evil.com` cannot bounce the user to an attacker site.
|
||||
function safeRedirect(target) {
|
||||
const t = String(target == null ? '' : target).trim();
|
||||
if (!t || t.startsWith('//') || t.startsWith('\\/')) return '/';
|
||||
if (!/^[/?#]/.test(t)) return '/';
|
||||
return t;
|
||||
}
|
||||
|
||||
// --- SSRF guard for user-configured external hosts -----------------------
|
||||
// Block addresses that would let a user point the server at cloud metadata /
|
||||
// link-local services (the high-severity SSRF: stealing the instance's cloud
|
||||
// credentials). Loopback and private ranges stay allowed on purpose: the app
|
||||
// explicitly supports a local Ollama / CalDAV on the same host or LAN.
|
||||
function isBlockedSsrfIp(ip) {
|
||||
if (!ip) return true;
|
||||
const m = ip.split('.');
|
||||
if (m.length === 4 && m.every((x) => /^\d+$/.test(x))) {
|
||||
const [a, b] = m.map(Number);
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
if (a === 169 && b === 254) return true; // link-local / cloud metadata
|
||||
}
|
||||
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
|
||||
if (/^fe80:/i.test(ip)) return true; // IPv6 link-local
|
||||
return false;
|
||||
}
|
||||
function resolveHost(host) {
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup(host, { all: true }, (err, addrs) => {
|
||||
if (err || !addrs || !addrs.length) return resolve([]);
|
||||
resolve(addrs.map((a) => a.address));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function assertSafeUrl(raw, { requireTls = false } = {}) {
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { throw new Error('Ungültige URL'); }
|
||||
if (!/^https?:$/.test(u.protocol)) throw new Error('Nur http/https erlaubt');
|
||||
if (requireTls && u.protocol !== 'https:') throw new Error('HTTPS erforderlich');
|
||||
if (isBlockedSsrfIp(u.hostname)) throw new Error('Interne Adresse nicht erlaubt');
|
||||
for (const ip of await resolveHost(u.hostname)) {
|
||||
if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
|
||||
}
|
||||
}
|
||||
async function assertSafeHost(raw) {
|
||||
const h = String(raw || '').split(':')[0].trim();
|
||||
if (!h) throw new Error('Host fehlt');
|
||||
if (isBlockedSsrfIp(h)) throw new Error('Interne Adresse nicht erlaubt');
|
||||
for (const ip of await resolveHost(h)) {
|
||||
if (isBlockedSsrfIp(ip)) throw new Error('Interne Adresse nicht erlaubt');
|
||||
}
|
||||
}
|
||||
|
||||
// Promise wrapper for db operations
|
||||
function dbGet(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -481,32 +677,51 @@ async function destroySession(token) {
|
||||
if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
|
||||
}
|
||||
|
||||
// Resolve the user a session token belongs to, or null. Touches last_seen.
|
||||
// Resolve the user a session token belongs to, or null. Touches last_seen and
|
||||
// enforces the server-side absolute session expiry: a cookie is only valid for
|
||||
// SESSION_MAX_AGE after the last activity, regardless of the client-side
|
||||
// maxAge (which a client can tamper with). Expired rows are deleted.
|
||||
async function loadSessionUser(token) {
|
||||
if (!token) return null;
|
||||
const row = await dbGet(
|
||||
`SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin
|
||||
`SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin,
|
||||
s.created_at AS created_at, s.last_seen AS last_seen
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?`,
|
||||
[token]
|
||||
);
|
||||
if (!row) return null;
|
||||
const stamp = row.last_seen || row.created_at; // 'YYYY-MM-DD HH:MM:SS' (UTC)
|
||||
const last = new Date(stamp + 'Z');
|
||||
if (isNaN(last.getTime()) || (Date.now() - last.getTime()) / 1000 > SESSION_MAX_AGE) {
|
||||
await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {});
|
||||
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
|
||||
}
|
||||
|
||||
// Find a user by username + password (login check). Returns the user object or null.
|
||||
// When the username is unknown we still run a dummy scrypt verify against a fixed
|
||||
// hash, so a missing user takes the same time as a wrong password (no user
|
||||
// enumeration via timing).
|
||||
async function authenticate(username, plain) {
|
||||
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
|
||||
if (!row) return null;
|
||||
if (!password.verify(plain || '', row.password_hash)) return null;
|
||||
if (!row) {
|
||||
await password.verify(plain || '', password.DUMMY_HASH);
|
||||
return null;
|
||||
}
|
||||
if (!(await password.verify(plain || '', row.password_hash))) return null;
|
||||
return { id: row.id, username: row.username, is_admin: !!row.is_admin };
|
||||
}
|
||||
|
||||
// Set/clear the session cookie on a response.
|
||||
// Set/clear the session cookie on a response. The Secure flag is set whenever
|
||||
// the request arrived over TLS (Caddy terminates it; trust proxy lets us see
|
||||
// that via req.secure), so the cookie is never leaked over plain HTTP.
|
||||
function setSessionCookie(res, token) {
|
||||
const secure = !!(res.req && res.req.secure);
|
||||
res.cookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000,
|
||||
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000, secure,
|
||||
});
|
||||
}
|
||||
function clearSessionCookie(res) {
|
||||
@@ -879,11 +1094,14 @@ async function runGeneration(bewerbungId, options = {}) {
|
||||
|
||||
let seq = 0;
|
||||
const storeAnhang = async (name, filename, mime, buffer) => {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
||||
// Sanitise the filename segment so a stored ba.dateiname like '../x' can
|
||||
// never escape the user's anhaenge directory via the on-disk path.
|
||||
const safe = safeFilename(filename);
|
||||
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${safe}`;
|
||||
fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer);
|
||||
await dbRun(
|
||||
'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[U, bewerbungId, name, filename, mime, stored]
|
||||
[U, bewerbungId, name, safe, mime, stored]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -894,8 +1112,8 @@ async function runGeneration(bewerbungId, options = {}) {
|
||||
|
||||
// Selected extra attachments (e.g. Zeugnisse) — copied as-is
|
||||
for (const ba of selectedAnhaenge) {
|
||||
const src = path.join(userStorageDir(basisAnhaengeDir), ba.pfad);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
const src = containedPath(userStorageDir(basisAnhaengeDir), ba.pfad);
|
||||
if (!src || !fs.existsSync(src)) continue;
|
||||
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
||||
}
|
||||
|
||||
@@ -1755,7 +1973,10 @@ initializeDatabase().then(async () => {
|
||||
// Remove private attachments belonging to the internal notes.
|
||||
const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
const intDir = userStorageDir(interneAnhaengeDir);
|
||||
interne.forEach((a) => fs.promises.unlink(path.join(intDir, a.pfad)).catch(() => {}));
|
||||
interne.forEach((a) => {
|
||||
const fp = containedPath(intDir, a.pfad);
|
||||
if (fp) fs.promises.unlink(fp).catch(() => {});
|
||||
});
|
||||
await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
|
||||
@@ -1971,8 +2192,8 @@ initializeDatabase().then(async () => {
|
||||
for (const aid of anhangIds) {
|
||||
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
|
||||
if (!a) continue;
|
||||
const p = path.join(anhaengeUserDir, a.pfad);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const p = containedPath(anhaengeUserDir, a.pfad);
|
||||
if (!p || !fs.existsSync(p)) continue;
|
||||
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
|
||||
attNames.push(a.dateiname);
|
||||
}
|
||||
@@ -1985,8 +2206,8 @@ initializeDatabase().then(async () => {
|
||||
for (const bid of basisAnlageIds) {
|
||||
const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
|
||||
if (!ba) continue;
|
||||
const p = path.join(basisUserDir, ba.pfad);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const p = containedPath(basisUserDir, ba.pfad);
|
||||
if (!p || !fs.existsSync(p)) continue;
|
||||
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
|
||||
attNames.push(ba.dateiname);
|
||||
}
|
||||
@@ -2066,7 +2287,7 @@ initializeDatabase().then(async () => {
|
||||
|
||||
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
|
||||
app.post('/email/fetch', async (req, res) => {
|
||||
const back = req.body.back || req.get('referer') || '/';
|
||||
const back = safeRedirect(req.body.back || req.get('referer') || '/');
|
||||
try {
|
||||
await pollInbox();
|
||||
} catch (e) { /* errors are logged inside pollInbox */ }
|
||||
@@ -2078,11 +2299,11 @@ initializeDatabase().then(async () => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!a) return res.status(404).send('Anhang nicht gefunden');
|
||||
const p = path.join(userStorageDir(emailAnhaengeDir), a.pfad);
|
||||
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
|
||||
const p = containedPath(userStorageDir(emailAnhaengeDir), a.pfad);
|
||||
if (!p || !fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
|
||||
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
|
||||
if (req.query.inline === '1') return serveInline(res, p, a.name || a.pfad, a.mime);
|
||||
res.download(p, a.name || a.pfad);
|
||||
res.download(p, displayFilename(a.name || a.pfad));
|
||||
} catch (error) {
|
||||
console.error('Error downloading e-mail attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
@@ -2164,7 +2385,8 @@ initializeDatabase().then(async () => {
|
||||
const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||||
const attDir = userStorageDir(emailAnhaengeDir);
|
||||
for (const a of atts) {
|
||||
fs.promises.unlink(path.join(attDir, a.pfad)).catch(() => {});
|
||||
const fp = containedPath(attDir, a.pfad);
|
||||
if (fp) fs.promises.unlink(fp).catch(() => {});
|
||||
}
|
||||
await dbRun('DELETE FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||||
await dbRun('DELETE FROM emails WHERE id = ? AND user_id = ?', [emailId, uid()]);
|
||||
@@ -2598,9 +2820,9 @@ initializeDatabase().then(async () => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
||||
const filePath = path.join(userStorageDir(basisAnhaengeDir), a.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(filePath, a.dateiname);
|
||||
const filePath = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
|
||||
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(filePath, displayFilename(a.dateiname));
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
@@ -2612,7 +2834,8 @@ initializeDatabase().then(async () => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (a) {
|
||||
fs.promises.unlink(path.join(userStorageDir(basisAnhaengeDir), a.pfad)).catch(() => {});
|
||||
const fp = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
|
||||
if (fp) fs.promises.unlink(fp).catch(() => {});
|
||||
await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
@@ -2703,11 +2926,11 @@ initializeDatabase().then(async () => {
|
||||
try {
|
||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
// inline=1 opens viewable files (PDF/image) in the browser tab instead of forcing a download.
|
||||
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
|
||||
res.download(filePath, anhang.dateiname);
|
||||
res.download(filePath, displayFilename(anhang.dateiname));
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
@@ -2720,8 +2943,8 @@ initializeDatabase().then(async () => {
|
||||
const { id, anhangId } = req.params;
|
||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||||
if (anhang) {
|
||||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
fs.promises.unlink(filePath).catch(() => {});
|
||||
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
if (filePath) fs.promises.unlink(filePath).catch(() => {});
|
||||
await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||||
}
|
||||
res.redirect('/bewerbung/' + id);
|
||||
@@ -2766,10 +2989,10 @@ initializeDatabase().then(async () => {
|
||||
const { id, anhangId } = req.params;
|
||||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||||
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||
const filePath = path.join(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
const filePath = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
||||
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime);
|
||||
res.download(filePath, anhang.dateiname);
|
||||
res.download(filePath, displayFilename(anhang.dateiname));
|
||||
} catch (error) {
|
||||
console.error('Error downloading internal attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
@@ -2782,7 +3005,8 @@ initializeDatabase().then(async () => {
|
||||
const { id, anhangId } = req.params;
|
||||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||||
if (anhang) {
|
||||
fs.promises.unlink(path.join(userStorageDir(interneAnhaengeDir), anhang.pfad)).catch(() => {});
|
||||
const fp = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
||||
if (fp) fs.promises.unlink(fp).catch(() => {});
|
||||
await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||||
}
|
||||
res.redirect('/bewerbung/' + id);
|
||||
@@ -2809,7 +3033,8 @@ initializeDatabase().then(async () => {
|
||||
const anhaengeUserDir = userStorageDir(anhaengeDir);
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeUserDir, a.pfad)).catch(() => {});
|
||||
const fp = containedPath(anhaengeUserDir, a.pfad);
|
||||
if (fp) fs.promises.unlink(fp).catch(() => {});
|
||||
}
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
||||
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?", [id, U]);
|
||||
@@ -3103,6 +3328,19 @@ initializeDatabase().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// SSRF guard: the user can configure external hosts the server will call
|
||||
// (Ollama, CalDAV, mail). Validate them against a metadata/link-local
|
||||
// blocklist so a user cannot point the server at a cloud metadata endpoint
|
||||
// to exfiltrate instance credentials. Localhost / LAN stays allowed on
|
||||
// purpose (the app supports a local Ollama or on-prem CalDAV).
|
||||
try {
|
||||
if ((werte.OLLAMA_HOST || '').trim()) await assertSafeUrl(werte.OLLAMA_HOST.trim());
|
||||
if ((werte.CALDAV_URL || '').trim()) await assertSafeUrl(werte.CALDAV_URL.trim());
|
||||
if ((werte.MAIL_HOST || '').trim()) await assertSafeHost(werte.MAIL_HOST.trim());
|
||||
} catch (vErr) {
|
||||
return res.status(400).send('Einstellung abgelehnt: ' + (vErr.message || 'ungültiger Host'));
|
||||
}
|
||||
|
||||
await config.saveAll(werte);
|
||||
res.redirect('/einstellungen');
|
||||
} catch (error) {
|
||||
@@ -3740,6 +3978,16 @@ initializeDatabase().then(async () => {
|
||||
setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
|
||||
setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile
|
||||
|
||||
// Global error handler: never leak internals. API/AJAX callers get JSON, everyone
|
||||
// else gets a plain 500. Logged server-side with the stack for debugging.
|
||||
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
||||
if (res.headersSent) return next(err);
|
||||
console.error('Unhandled error:', err);
|
||||
const wantsJson = (req.get('accept') || '').includes('application/json') || req.path.startsWith('/api/') || req.xhr;
|
||||
if (wantsJson) return res.status(500).json({ error: 'Serverfehler' });
|
||||
res.status(500).send('Serverfehler');
|
||||
});
|
||||
|
||||
// Handle 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).send('Seite nicht gefunden');
|
||||
|
||||
Reference in New Issue
Block a user