Security-Hardening Runde 3: JWT, trust-proxy, weitere IDORs, Attachment-Härtung

- JWT-Algorithmus fest auf HS256 (Defense-in-Depth gegen alg-confusion)
- app.set('trust proxy', 1) – Rate-Limiter wirkt jetzt auch hinter Reverse-Proxy
- IDOR-Fix: Invoice-ECD-Endpoints + PDF-Template-Generierung (canAccessContract/ECD)
- Email-Anhang-Download: Content-Type-Safelist, SVG nie inline, nosniff, Filename-CRLF-Sanitize
- Provider/Tariff-GET-Routen: requirePermission('providers:read') (Portal-Kunden raus)
- SMTP-Header-Injection zentral in sendEmail blockiert (schützt alle Caller)
- bcrypt-Cost 10 → 12 (OWASP 2026)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-24 09:38:25 +02:00
parent 301aafffd1
commit 8aead8c2f6
11 changed files with 131 additions and 21 deletions

View File

@ -256,12 +256,30 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
}
}
// 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<void> {
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) {

View File

@ -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<void> {
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
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<void> {
/**
* Einzelne Rechnung abrufen
*/
export async function getInvoice(req: Request, res: Response): Promise<void> {
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
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<void> {
/**
* Neue Rechnung hinzufügen
*/
export async function addInvoice(req: Request, res: Response): Promise<void> {
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
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<void> {
/**
* Rechnung aktualisieren
*/
export async function updateInvoice(req: Request, res: Response): Promise<void> {
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
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<void>
/**
* Rechnung löschen
*/
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
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);

View File

@ -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;

View File

@ -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.)

View File

@ -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) {

View File

@ -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;

View File

@ -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);

View File

@ -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: {

View File

@ -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<SendEmailResult> {
// 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;

View File

@ -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<boolean> {
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',
);
}

View File

@ -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**