diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 15796c96..c393b3cb 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -256,12 +256,30 @@ export async function syncAccount(req: Request, res: Response): Promise { } } +// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern. +// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand +// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler. +function hasCRLF(value: unknown): boolean { + if (typeof value === 'string') return /[\r\n]/.test(value); + if (Array.isArray(value)) return value.some(hasCRLF); + return false; +} + // E-Mail senden export async function sendEmailFromAccount(req: Request, res: Response): Promise { try { const stressfreiEmailId = parseInt(req.params.id); const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body; + // Header-Injection (CRLF) in Empfänger/Betreff ablehnen + if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) { + res.status(400).json({ + success: false, + error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)', + } as ApiResponse); + return; + } + // StressfreiEmail laden const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId); @@ -514,10 +532,26 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi return; } - // Datei senden - inline (öffnen) oder attachment (download) - const disposition = req.query.view === 'true' ? 'inline' : 'attachment'; - res.setHeader('Content-Type', attachment.contentType); - res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`); + // Security: Content-Type aus IMAP kommt vom Absender und kann `text/html` + // o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen + // zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS + // via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen. + const INLINE_SAFE_TYPES = new Set([ + 'application/pdf', + 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', + 'image/svg+xml' /* wird unten trotzdem als download erzwungen */, + 'text/plain', + ]); + const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase(); + // SVG kann Skripte enthalten → niemals inline + const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml'; + const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment'; + const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment'; + // Filename: Steuerzeichen entfernen (CRLF-Injection in Header) + const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream'); + res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`); res.setHeader('Content-Length', attachment.size); res.send(attachment.content); } catch (error) { diff --git a/backend/src/controllers/invoice.controller.ts b/backend/src/controllers/invoice.controller.ts index f580929c..06db33e8 100644 --- a/backend/src/controllers/invoice.controller.ts +++ b/backend/src/controllers/invoice.controller.ts @@ -2,14 +2,15 @@ import { Request, Response } from 'express'; import * as invoiceService from '../services/invoice.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; -import { canAccessContract } from '../utils/accessControl.js'; +import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js'; /** * Alle Rechnungen für ein EnergyContractDetails abrufen */ -export async function getInvoices(req: Request, res: Response): Promise { +export async function getInvoices(req: AuthRequest, res: Response): Promise { try { const ecdId = parseInt(req.params.ecdId); + if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return; const invoices = await invoiceService.getInvoices(ecdId); res.json({ success: true, data: invoices } as ApiResponse); } catch (error) { @@ -24,10 +25,11 @@ export async function getInvoices(req: Request, res: Response): Promise { /** * Einzelne Rechnung abrufen */ -export async function getInvoice(req: Request, res: Response): Promise { +export async function getInvoice(req: AuthRequest, res: Response): Promise { try { const ecdId = parseInt(req.params.ecdId); const invoiceId = parseInt(req.params.invoiceId); + if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return; const invoice = await invoiceService.getInvoice(ecdId, invoiceId); if (!invoice) { @@ -51,9 +53,10 @@ export async function getInvoice(req: Request, res: Response): Promise { /** * Neue Rechnung hinzufügen */ -export async function addInvoice(req: Request, res: Response): Promise { +export async function addInvoice(req: AuthRequest, res: Response): Promise { try { const ecdId = parseInt(req.params.ecdId); + if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return; const { invoiceDate, invoiceType, documentPath, notes } = req.body; if (!invoiceDate || !invoiceType) { @@ -90,10 +93,11 @@ export async function addInvoice(req: Request, res: Response): Promise { /** * Rechnung aktualisieren */ -export async function updateInvoice(req: Request, res: Response): Promise { +export async function updateInvoice(req: AuthRequest, res: Response): Promise { try { const ecdId = parseInt(req.params.ecdId); const invoiceId = parseInt(req.params.invoiceId); + if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return; const { invoiceDate, invoiceType, documentPath, notes } = req.body; const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, { @@ -122,10 +126,11 @@ export async function updateInvoice(req: Request, res: Response): Promise /** * Rechnung löschen */ -export async function deleteInvoice(req: Request, res: Response): Promise { +export async function deleteInvoice(req: AuthRequest, res: Response): Promise { try { const ecdId = parseInt(req.params.ecdId); const invoiceId = parseInt(req.params.invoiceId); + if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return; await invoiceService.deleteInvoice(ecdId, invoiceId); diff --git a/backend/src/controllers/pdfTemplate.controller.ts b/backend/src/controllers/pdfTemplate.controller.ts index fcf08c32..d1be246a 100644 --- a/backend/src/controllers/pdfTemplate.controller.ts +++ b/backend/src/controllers/pdfTemplate.controller.ts @@ -2,6 +2,7 @@ import { Response } from 'express'; import { AuthRequest } from '../types/index.js'; import * as pdfTemplateService from '../services/pdfTemplate.service.js'; import { logChange } from '../services/audit.service.js'; +import { canAccessContract } from '../utils/accessControl.js'; export async function getTemplates(req: AuthRequest, res: Response) { try { @@ -149,6 +150,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) { try { const templateId = parseInt(req.params.id); const contractId = parseInt(req.params.contractId); + if (!(await canAccessContract(req, res, contractId))) return; const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId); res.json({ success: true, data: inputs }); } catch (error) { @@ -160,6 +162,7 @@ export async function generatePdf(req: AuthRequest, res: Response) { try { const templateId = parseInt(req.params.id); const contractId = parseInt(req.params.contractId); + if (!(await canAccessContract(req, res, contractId))) return; // Extras aus Body (POST) oder Query-Parametern (GET) const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId; diff --git a/backend/src/index.ts b/backend/src/index.ts index 589f330d..fa03ef4b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -55,6 +55,12 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) { const app = express(); const PORT = process.env.PORT || 3001; +// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost. +// `trust proxy = 1` = dem ersten Hop X-Forwarded-For vertrauen (damit req.ip +// die echte Client-IP ist). Wichtig für express-rate-limit, sonst teilen sich +// alle Requests dieselbe Proxy-IP und das Rate-Limit ist unwirksam. +app.set('trust proxy', 1); + // ==================== SECURITY MIDDLEWARE ==================== // HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 32ee6adb..84f856af 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -27,7 +27,10 @@ export async function authenticate( try { // JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts) - const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload; + // Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion). + const decoded = jwt.verify(token, process.env.JWT_SECRET as string, { + algorithms: ['HS256'], + }) as JwtPayload; // Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde if (decoded.userId && decoded.iat) { diff --git a/backend/src/routes/provider.routes.ts b/backend/src/routes/provider.routes.ts index 681cb4e5..98668617 100644 --- a/backend/src/routes/provider.routes.ts +++ b/backend/src/routes/provider.routes.ts @@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); -// Provider routes -router.get('/', authenticate, providerController.getProviders); +// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen) +router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders); router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider); -router.get('/:id', authenticate, providerController.getProvider); +router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider); router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider); router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider); // Nested tariff routes -router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs); +router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs); router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff); export default router; diff --git a/backend/src/routes/tariff.routes.ts b/backend/src/routes/tariff.routes.ts index d6c278fe..c62c3354 100644 --- a/backend/src/routes/tariff.routes.ts +++ b/backend/src/routes/tariff.routes.ts @@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); // Standalone tariff routes (for update/delete by tariff id) -router.get('/:id', authenticate, tariffController.getTariff); +router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff); router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff); router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 74855497..d49b4d48 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -7,6 +7,10 @@ import { encrypt, decrypt } from '../utils/encryption.js'; import { sendEmail, SmtpCredentials } from './smtpService.js'; import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; +// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash. +// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash). +const BCRYPT_COST = 12; + // Mitarbeiter-Login export async function login(email: string, password: string) { const user = await prisma.user.findUnique({ @@ -168,7 +172,7 @@ export async function customerLogin(email: string, password: string) { export async function setCustomerPortalPassword(customerId: number, password: string) { console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId); - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = await bcrypt.hash(password, BCRYPT_COST); const encryptedPassword = encrypt(password); console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length); @@ -211,7 +215,7 @@ export async function createUser(data: { roleIds: number[]; customerId?: number; }) { - const hashedPassword = await bcrypt.hash(data.password, 10); + const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST); const user = await prisma.user.create({ data: { @@ -471,7 +475,7 @@ export async function confirmPasswordReset(token: string, newPassword: string): throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); } - const hash = await bcrypt.hash(newPassword, 10); + const hash = await bcrypt.hash(newPassword, BCRYPT_COST); await prisma.user.update({ where: { id: user.id }, data: { @@ -493,7 +497,7 @@ export async function confirmPasswordReset(token: string, newPassword: string): throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); } - const hash = await bcrypt.hash(newPassword, 10); + const hash = await bcrypt.hash(newPassword, BCRYPT_COST); await prisma.customer.update({ where: { id: customer.id }, data: { diff --git a/backend/src/services/smtpService.ts b/backend/src/services/smtpService.ts index 2330a3b0..9d2222fd 100644 --- a/backend/src/services/smtpService.ts +++ b/backend/src/services/smtpService.ts @@ -49,6 +49,16 @@ export interface EmailLogContext { triggeredBy?: string; // User-Email } +// Security: zentrale CRLF-Prüfung gegen SMTP-Header-Injection. +// Alle Felder, die als Header ausgehen (to/cc/subject/replyTo/references/from), +// werden hier geprüft – egal ob der Caller aus cachedEmail, birthday, gdpr, +// consent-public oder auth kommt. +function containsCRLF(value: unknown): boolean { + if (typeof value === 'string') return /[\r\n]/.test(value); + if (Array.isArray(value)) return value.some(containsCRLF); + return false; +} + // E-Mail senden export async function sendEmail( credentials: SmtpCredentials, @@ -56,6 +66,21 @@ export async function sendEmail( params: SendEmailParams, logContext?: EmailLogContext ): Promise { + // Header-Injection-Guard (defensiv: Absender, Empfänger, Subject) + if ( + containsCRLF(fromAddress) || + containsCRLF(params.to) || + containsCRLF(params.cc) || + containsCRLF(params.subject) || + containsCRLF(params.inReplyTo) || + containsCRLF(params.references) + ) { + return { + success: false, + error: 'Ungültige Zeichen in E-Mail-Header-Feldern (CRLF nicht erlaubt)', + }; + } + // Verschlüsselungs-Einstellungen basierend auf Modus const encryption = credentials.encryption ?? 'SSL'; const rejectUnauthorized = !credentials.allowSelfSignedCerts; diff --git a/backend/src/utils/accessControl.ts b/backend/src/utils/accessControl.ts index ef8f4c1b..bf1c7162 100644 --- a/backend/src/utils/accessControl.ts +++ b/backend/src/utils/accessControl.ts @@ -224,3 +224,24 @@ export async function canAccessCachedEmail( 'E-Mail', ); } + +/** + * Zugriff auf ein EnergyContractDetails prüfen (ECD → Contract → customerId). + */ +export async function canAccessEnergyContractDetails( + req: AuthRequest, + res: Response, + ecdId: number, +): Promise { + if (!req.user?.isCustomerPortal) return true; + const ecd = await prisma.energyContractDetails.findUnique({ + where: { id: ecdId }, + select: { contract: { select: { customerId: true } } }, + }); + return canAccessResourceByCustomerId( + req, + res, + ecd?.contract?.customerId, + 'Energievertrag', + ); +} diff --git a/backend/todo.md b/backend/todo.md index 978196c0..222e6adc 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -97,7 +97,7 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt -- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (2 Runden)** +- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)** - Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)** - **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:** - CORS offen → `CORS_ORIGINS` explizit @@ -113,6 +113,15 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!) - 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …) - Path-Traversal bei Backup-Name und GDPR-Proof-Download + - **Runde 3 – Tiefer Dive (8 weitere Hardenings):** + - JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt + - `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam) + - IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId + - IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract` + - Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing + - Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr) + - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller) + - bcrypt cost 10 → 12 (OWASP 2026) - Deployment-Checkliste komplett - [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**