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 // 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) => {
const idx = stored.indexOf(':'); if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false);
const salt = stored.slice(0, idx); const idx = stored.indexOf(':');
const expected = stored.slice(idx + 1); const salt = stored.slice(0, idx);
if (!salt || !expected) return false; const expected = stored.slice(idx + 1);
let computed; if (!salt || !expected) return resolve(false);
try { crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => {
computed = crypto.scryptSync(password, salt, KEYLEN).toString('hex'); if (err) return resolve(false);
} catch (e) { const computed = buf.toString('hex');
return false; if (computed.length !== expected.length) return resolve(false);
} // Constant-time compare to avoid timing side channels.
if (computed.length !== expected.length) return false; resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)));
// Constant-time compare to avoid timing side channels. });
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)); });
} }
module.exports = { hash, verify }; module.exports = { hash, verify, DUMMY_HASH };
+10 -2
View File
@@ -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
View File
@@ -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();
File diff suppressed because one or more lines are too long
+398
View File
File diff suppressed because one or more lines are too long
+297 -49
View File
@@ -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
// 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) { function serveInline(res, filePath, filename, mime) {
const type = mime || { res.setHeader('X-Content-Type-Options', 'nosniff');
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', const ext = path.extname(filename || filePath).toLowerCase();
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', const type = INLINE_TYPES[ext] || INLINE_TYPES[path.extname(filePath).toLowerCase()];
}[path.extname(filename || filePath).toLowerCase()]; const safe = safeFilename(filename).replace(/["\r\n]/g, '');
if (type) res.type(type); if (type) {
const safe = String(filename || 'datei').replace(/["\r\n]/g, ''); 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', res.setHeader('Content-Disposition',
`inline; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`); `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`);
res.sendFile(filePath); 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, '&#39;'); .replace(/'/g, '&#39;');
} }
// --- 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
View File
@@ -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);
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>