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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user