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:
+20
-11
@@ -3,10 +3,17 @@
|
|||||||
// No external dependency (bcrypt would need a native build step). scrypt is
|
// No external dependency (bcrypt would need a native build step). scrypt is
|
||||||
// memory-hard and well suited for interactive logins. Hash format:
|
// memory-hard and well suited for interactive logins. Hash format:
|
||||||
// "<saltHex>:<hashHex>" (salt is 16 bytes, hash is 64 bytes)
|
// "<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 crypto = require('crypto');
|
||||||
|
|
||||||
const KEYLEN = 64;
|
const KEYLEN = 64;
|
||||||
|
const DUMMY_SALT = '00000000000000000000000000000000';
|
||||||
|
const DUMMY_HASH = `${DUMMY_SALT}:${crypto.scryptSync('nextjobs-dummy', DUMMY_SALT, KEYLEN).toString('hex')}`;
|
||||||
|
|
||||||
function hash(password) {
|
function hash(password) {
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
@@ -14,21 +21,23 @@ function hash(password) {
|
|||||||
return `${salt}:${out}`;
|
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) {
|
function verify(password, stored) {
|
||||||
if (typeof stored !== 'string' || !stored.includes(':')) return false;
|
return new Promise((resolve) => {
|
||||||
|
if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false);
|
||||||
const idx = stored.indexOf(':');
|
const idx = stored.indexOf(':');
|
||||||
const salt = stored.slice(0, idx);
|
const salt = stored.slice(0, idx);
|
||||||
const expected = stored.slice(idx + 1);
|
const expected = stored.slice(idx + 1);
|
||||||
if (!salt || !expected) return false;
|
if (!salt || !expected) return resolve(false);
|
||||||
let computed;
|
crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => {
|
||||||
try {
|
if (err) return resolve(false);
|
||||||
computed = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
|
const computed = buf.toString('hex');
|
||||||
} catch (e) {
|
if (computed.length !== expected.length) return resolve(false);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (computed.length !== expected.length) return false;
|
|
||||||
// Constant-time compare to avoid timing side channels.
|
// Constant-time compare to avoid timing side channels.
|
||||||
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
|
resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { hash, verify };
|
module.exports = { hash, verify, DUMMY_HASH };
|
||||||
+10
-2
@@ -24,13 +24,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minimal markdown → HTML. HTML is escaped first, so model output can never
|
// Minimal markdown → HTML. HTML is escaped first, so model output can never
|
||||||
// inject markup; only the markdown tokens below are turned into tags.
|
// inject markup; only the markdown tokens below are turned into tags. Link
|
||||||
|
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
|
||||||
|
// from the model cannot become a clickable XSS vector, and the URL is stripped
|
||||||
|
// of characters that could break out of the href attribute.
|
||||||
function inlineFmt(t) {
|
function inlineFmt(t) {
|
||||||
return t
|
return t
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
||||||
|
const u = String(url).trim();
|
||||||
|
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
|
||||||
|
const safe = u.replace(/["'<>`\\]/g, '');
|
||||||
|
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(src) {
|
function renderMarkdown(src) {
|
||||||
|
|||||||
+4
-3
@@ -612,13 +612,14 @@ function loadPdfLibraries() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load jsPDF from CDN
|
// Load jsPDF from the app's own /vendor (self-hosted) so no external
|
||||||
|
// script source has to be trusted in the Content-Security-Policy.
|
||||||
const script1 = document.createElement('script');
|
const script1 = document.createElement('script');
|
||||||
script1.id = 'jspdf-script';
|
script1.id = 'jspdf-script';
|
||||||
script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
|
script1.src = '/vendor/jspdf.umd.min.js';
|
||||||
script1.onload = () => {
|
script1.onload = () => {
|
||||||
const script2 = document.createElement('script');
|
const script2 = document.createElement('script');
|
||||||
script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js';
|
script2.src = '/vendor/jspdf.plugin.autotable.min.js';
|
||||||
script2.onload = () => {
|
script2.onload = () => {
|
||||||
pdfLibrariesLoaded = true;
|
pdfLibrariesLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
+10
File diff suppressed because one or more lines are too long
Vendored
+398
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ const sqlite3 = require('sqlite3').verbose();
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const dns = require('dns');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
|
|
||||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||||
@@ -79,6 +80,55 @@ function deriveArt(url, provided) {
|
|||||||
return 'Firmenwebsite';
|
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
|
// Middleware
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
@@ -150,6 +200,33 @@ function requireAuth(req, res, next) {
|
|||||||
return res.redirect('/login');
|
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.
|
// Login page + form handler.
|
||||||
app.get('/login', (req, res) => {
|
app.get('/login', (req, res) => {
|
||||||
if (req.user) return res.redirect('/');
|
if (req.user) return res.redirect('/');
|
||||||
@@ -158,10 +235,15 @@ app.get('/login', (req, res) => {
|
|||||||
|
|
||||||
app.post('/login', async (req, res) => {
|
app.post('/login', async (req, res) => {
|
||||||
const { username, password: plain } = req.body || {};
|
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);
|
const user = await authenticate(username, plain);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
loginFail(req);
|
||||||
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
|
return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' });
|
||||||
}
|
}
|
||||||
|
loginOk(req);
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
setSessionCookie(res, token);
|
setSessionCookie(res, token);
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
@@ -217,19 +299,29 @@ function userStorageDirForReq(base, req) {
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve a stored file with `Content-Disposition: inline` so the browser opens it
|
// Serve a stored file inline ONLY for a small allow-list of viewable types,
|
||||||
// (PDF/image) in a tab instead of downloading. Falls back to the file extension
|
// decided by the file *extension* (never a client-/sender-supplied MIME). Every
|
||||||
// when no MIME type is stored.
|
// other type is forced to a download, so an uploaded .html / .svg / odd MIME can
|
||||||
function serveInline(res, filePath, filename, mime) {
|
// never render as an active document in the app origin (stored XSS).
|
||||||
const type = mime || {
|
const INLINE_TYPES = {
|
||||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||||
}[path.extname(filename || filePath).toLowerCase()];
|
};
|
||||||
if (type) res.type(type);
|
function serveInline(res, filePath, filename, mime) {
|
||||||
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',
|
res.setHeader('Content-Disposition',
|
||||||
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||||||
res.sendFile(filePath);
|
return res.sendFile(filePath);
|
||||||
|
}
|
||||||
|
// Not a viewable type → download as an attachment, never inline.
|
||||||
|
res.setHeader('Content-Disposition',
|
||||||
|
`attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
|
||||||
|
res.download(filePath, safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- E-mail display + reply helpers --------------------------------------
|
// --- 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)
|
// 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({
|
const uploadBasisAnhang = multer({
|
||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
|
||||||
@@ -301,6 +403,7 @@ const uploadBasisAnhang = multer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||||
|
fileFilter: ATTACHMENT_FILTER,
|
||||||
}).single('datei');
|
}).single('datei');
|
||||||
|
|
||||||
// Directory for private attachments the user keeps alongside the internal
|
// Directory for private attachments the user keeps alongside the internal
|
||||||
@@ -319,6 +422,7 @@ const uploadInterneAnhang = multer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||||
|
fileFilter: ATTACHMENT_FILTER,
|
||||||
}).single('datei');
|
}).single('datei');
|
||||||
|
|
||||||
// Directory + uploader for the applicant's signature image (used in the letter)
|
// Directory + uploader for the applicant's signature image (used in the letter)
|
||||||
@@ -329,13 +433,17 @@ if (!fs.existsSync(signaturDir)) {
|
|||||||
const uploadSignatur = multer({
|
const uploadSignatur = multer({
|
||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(signaturDir, req)),
|
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) => {
|
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}`);
|
cb(null, `signatur_${Date.now()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
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');
|
}).single('signatur');
|
||||||
|
|
||||||
// The single stored signature file, if any (in the current user's subdir).
|
// The single stored signature file, if any (in the current user's subdir).
|
||||||
@@ -373,12 +481,14 @@ const uploadFoto = multer({
|
|||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
|
destination: (req, file, cb) => cb(null, userStorageDirForReq(fotoDir, req)),
|
||||||
filename: (req, file, cb) => {
|
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}`);
|
cb(null, `foto_${Date.now()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
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');
|
}).single('foto');
|
||||||
|
|
||||||
// The single stored photo file, if any (in the current user's subdir).
|
// The single stored photo file, if any (in the current user's subdir).
|
||||||
@@ -421,6 +531,92 @@ function sanitizeInput(input) {
|
|||||||
.replace(/'/g, ''');
|
.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
|
// Promise wrapper for db operations
|
||||||
function dbGet(sql, params = []) {
|
function dbGet(sql, params = []) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -481,32 +677,51 @@ async function destroySession(token) {
|
|||||||
if (token) await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
|
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) {
|
async function loadSessionUser(token) {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const row = await dbGet(
|
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
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token = ?`,
|
WHERE s.token = ?`,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (!row) return null;
|
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(() => {});
|
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 };
|
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.
|
// 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) {
|
async function authenticate(username, plain) {
|
||||||
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
|
const row = await dbGet('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', [username || '']);
|
||||||
if (!row) return null;
|
if (!row) {
|
||||||
if (!password.verify(plain || '', row.password_hash)) return null;
|
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 };
|
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) {
|
function setSessionCookie(res, token) {
|
||||||
|
const secure = !!(res.req && res.req.secure);
|
||||||
res.cookie(SESSION_COOKIE, token, {
|
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) {
|
function clearSessionCookie(res) {
|
||||||
@@ -879,11 +1094,14 @@ async function runGeneration(bewerbungId, options = {}) {
|
|||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const storeAnhang = async (name, filename, mime, buffer) => {
|
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);
|
fs.writeFileSync(path.join(userStorageDir(anhaengeDir), stored), buffer);
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO anhaenge (user_id, bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?, ?)',
|
'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
|
// Selected extra attachments (e.g. Zeugnisse) — copied as-is
|
||||||
for (const ba of selectedAnhaenge) {
|
for (const ba of selectedAnhaenge) {
|
||||||
const src = path.join(userStorageDir(basisAnhaengeDir), ba.pfad);
|
const src = containedPath(userStorageDir(basisAnhaengeDir), ba.pfad);
|
||||||
if (!fs.existsSync(src)) continue;
|
if (!src || !fs.existsSync(src)) continue;
|
||||||
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
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.
|
// 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 interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||||
const intDir = userStorageDir(interneAnhaengeDir);
|
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 interne_anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||||
await dbRun('DELETE FROM bewerbungen WHERE 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) {
|
for (const aid of anhangIds) {
|
||||||
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
|
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [aid, id, U]);
|
||||||
if (!a) continue;
|
if (!a) continue;
|
||||||
const p = path.join(anhaengeUserDir, a.pfad);
|
const p = containedPath(anhaengeUserDir, a.pfad);
|
||||||
if (!fs.existsSync(p)) continue;
|
if (!p || !fs.existsSync(p)) continue;
|
||||||
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
|
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
|
||||||
attNames.push(a.dateiname);
|
attNames.push(a.dateiname);
|
||||||
}
|
}
|
||||||
@@ -1985,8 +2206,8 @@ initializeDatabase().then(async () => {
|
|||||||
for (const bid of basisAnlageIds) {
|
for (const bid of basisAnlageIds) {
|
||||||
const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
|
const ba = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [bid, U]);
|
||||||
if (!ba) continue;
|
if (!ba) continue;
|
||||||
const p = path.join(basisUserDir, ba.pfad);
|
const p = containedPath(basisUserDir, ba.pfad);
|
||||||
if (!fs.existsSync(p)) continue;
|
if (!p || !fs.existsSync(p)) continue;
|
||||||
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
|
attachments.push({ filename: ba.dateiname, path: p, contentType: ba.mime || undefined });
|
||||||
attNames.push(ba.dateiname);
|
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.
|
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
|
||||||
app.post('/email/fetch', async (req, res) => {
|
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 {
|
try {
|
||||||
await pollInbox();
|
await pollInbox();
|
||||||
} catch (e) { /* errors are logged inside pollInbox */ }
|
} catch (e) { /* errors are logged inside pollInbox */ }
|
||||||
@@ -2078,11 +2299,11 @@ initializeDatabase().then(async () => {
|
|||||||
try {
|
try {
|
||||||
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
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');
|
if (!a) return res.status(404).send('Anhang nicht gefunden');
|
||||||
const p = path.join(userStorageDir(emailAnhaengeDir), a.pfad);
|
const p = containedPath(userStorageDir(emailAnhaengeDir), a.pfad);
|
||||||
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
|
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.
|
// 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);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error downloading e-mail attachment:', error);
|
console.error('Error downloading e-mail attachment:', error);
|
||||||
res.status(500).send('Serverfehler');
|
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 atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||||||
const attDir = userStorageDir(emailAnhaengeDir);
|
const attDir = userStorageDir(emailAnhaengeDir);
|
||||||
for (const a of atts) {
|
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 email_anhaenge WHERE email_id = ? AND user_id = ?', [emailId, uid()]);
|
||||||
await dbRun('DELETE FROM emails WHERE 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 {
|
try {
|
||||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
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');
|
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
||||||
const filePath = path.join(userStorageDir(basisAnhaengeDir), a.pfad);
|
const filePath = containedPath(userStorageDir(basisAnhaengeDir), a.pfad);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
if (!filePath || !fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||||
res.download(filePath, a.dateiname);
|
res.download(filePath, displayFilename(a.dateiname));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading attachment:', error);
|
console.error('Error downloading attachment:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
@@ -2612,7 +2834,8 @@ initializeDatabase().then(async () => {
|
|||||||
try {
|
try {
|
||||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
if (a) {
|
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()]);
|
await dbRun('DELETE FROM basis_anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
}
|
}
|
||||||
res.redirect('/vorlagen');
|
res.redirect('/vorlagen');
|
||||||
@@ -2703,11 +2926,11 @@ initializeDatabase().then(async () => {
|
|||||||
try {
|
try {
|
||||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
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');
|
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
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.
|
// 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);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error downloading attachment:', error);
|
console.error('Error downloading attachment:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
@@ -2720,8 +2943,8 @@ initializeDatabase().then(async () => {
|
|||||||
const { id, anhangId } = req.params;
|
const { id, anhangId } = req.params;
|
||||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||||||
if (anhang) {
|
if (anhang) {
|
||||||
const filePath = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
const filePath = containedPath(userStorageDir(anhaengeDir), anhang.pfad);
|
||||||
fs.promises.unlink(filePath).catch(() => {});
|
if (filePath) fs.promises.unlink(filePath).catch(() => {});
|
||||||
await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
await dbRun('DELETE FROM anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||||||
}
|
}
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
@@ -2766,10 +2989,10 @@ initializeDatabase().then(async () => {
|
|||||||
const { id, anhangId } = req.params;
|
const { id, anhangId } = req.params;
|
||||||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
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');
|
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||||
const filePath = path.join(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
const filePath = containedPath(userStorageDir(interneAnhaengeDir), anhang.pfad);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
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);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error downloading internal attachment:', error);
|
console.error('Error downloading internal attachment:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
@@ -2782,7 +3005,8 @@ initializeDatabase().then(async () => {
|
|||||||
const { id, anhangId } = req.params;
|
const { id, anhangId } = req.params;
|
||||||
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [anhangId, id, uid()]);
|
||||||
if (anhang) {
|
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()]);
|
await dbRun('DELETE FROM interne_anhaenge WHERE id = ? AND user_id = ?', [anhangId, uid()]);
|
||||||
}
|
}
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
@@ -2809,7 +3033,8 @@ initializeDatabase().then(async () => {
|
|||||||
const anhaengeUserDir = userStorageDir(anhaengeDir);
|
const anhaengeUserDir = userStorageDir(anhaengeDir);
|
||||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, U]);
|
||||||
for (const a of alte) {
|
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('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]);
|
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);
|
await config.saveAll(werte);
|
||||||
res.redirect('/einstellungen');
|
res.redirect('/einstellungen');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3740,6 +3978,16 @@ initializeDatabase().then(async () => {
|
|||||||
setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
|
setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot
|
||||||
setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile
|
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
|
// Handle 404
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).send('Seite nicht gefunden');
|
res.status(404).send('Seite nicht gefunden');
|
||||||
|
|||||||
+5
-4
@@ -415,7 +415,7 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="px-4 pb-4">
|
<div class="px-4 pb-4">
|
||||||
<% if (application.quelle_url) { %>
|
<% if (application.quelle_url) { %>
|
||||||
<a href="<%= application.quelle_url %>" target="_blank" rel="noopener"
|
<a href="<%= safeUrl(application.quelle_url) %>" target="_blank" rel="noopener"
|
||||||
class="inline-block mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"><%= application.quelle_url %></a>
|
class="inline-block mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"><%= application.quelle_url %></a>
|
||||||
<% } %>
|
<% } %>
|
||||||
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= application.stellenbeschreibung %></p>
|
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= application.stellenbeschreibung %></p>
|
||||||
@@ -471,7 +471,7 @@
|
|||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
<% if (e.display_srcdoc) { %>
|
<% if (e.display_srcdoc) { %>
|
||||||
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer"
|
sandbox="allow-same-origin" referrerpolicy="no-referrer"
|
||||||
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= e.body_text || '' %></p>
|
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= e.body_text || '' %></p>
|
||||||
@@ -828,8 +828,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Size a sandboxed e-mail iframe to its content height. The sandbox
|
// Size a sandboxed e-mail iframe to its content height. The sandbox
|
||||||
// enables allow-same-origin (but not allow-scripts), so scripts in the
|
// keeps allow-same-origin (so we can read contentDocument to measure)
|
||||||
// mail never run, yet we can still measure the rendered document.
|
// but enables no scripts, no forms and no popups, so mail content can
|
||||||
|
// neither execute nor escape the frame.
|
||||||
function fitFrame(f) {
|
function fitFrame(f) {
|
||||||
try {
|
try {
|
||||||
const doc = f.contentDocument || (f.contentWindow && f.contentWindow.document);
|
const doc = f.contentDocument || (f.contentWindow && f.contentWindow.document);
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (j.quelle_url) { %>
|
<% if (j.quelle_url) { %>
|
||||||
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
|
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
||||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||||
<span class="truncate"><%= j.quelle_url %></span>
|
<span class="truncate"><%= j.quelle_url %></span>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (j.quelle_url) { %>
|
<% if (j.quelle_url) { %>
|
||||||
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
|
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
||||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||||
<span class="truncate"><%= j.quelle_url %></span>
|
<span class="truncate"><%= j.quelle_url %></span>
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@
|
|||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
<% if (e.display_srcdoc) { %>
|
<% if (e.display_srcdoc) { %>
|
||||||
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer"
|
sandbox="allow-same-origin" referrerpolicy="no-referrer"
|
||||||
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user