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:
2026-04-24 09:38:25 +02:00
parent 301aafffd1
commit 8aead8c2f6
11 changed files with 131 additions and 21 deletions
+8 -4
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: {
+25
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;