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:
parent
301aafffd1
commit
8aead8c2f6
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
Loading…
Reference in New Issue