Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure
Umfassender Security-Review vor öffentlichem Deployment. Detaillierter Report in docs/SECURITY-REVIEW.md. 🔴 KRITISCHE FIXES: 1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env), in Production per default komplett aus (gleiche Origin erzwingt Browser). 2. Keine Security-Headers → helmet-Middleware hinzugefügt. X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP. 3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind, sonst Fail-Fast mit klarer Fehlermeldung. 4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read' konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK, Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen). Neuer Helper canAccessContract() in utils/accessControl.ts in allen betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten. 5. XSS via Email-Body. email.htmlBody wurde ungefiltert via dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script> schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify sanitized: verbietet script/iframe/form/inline-handler, erlaubt normale Formatierung + Bilder. 6. Customer-API leakte sensible Felder: - portalPasswordHash (bcrypt-Hash) - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar) - portalPasswordResetToken (gültig 2h) Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers. Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige), alle anderen Rollen nicht. 🟡 WICHTIGE FIXES: 7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt. Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden dadurch invalidiert. 8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar). Neue Files: - backend/src/utils/accessControl.ts - IDOR-Schutz - backend/src/utils/sanitize.ts - Response-Sanitizer - docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -254,9 +255,12 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContractPassword(req: Request, res: Response): Promise<void> {
|
||||
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const password = await contractService.getContractPassword(parseInt(req.params.id));
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const password = await contractService.getContractPassword(contractId);
|
||||
if (password === null) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -273,9 +277,21 @@ export async function getContractPassword(req: Request, res: Response): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
|
||||
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
|
||||
const simCardId = parseInt(req.params.simCardId);
|
||||
// SimCard → MobileDetails → Contract
|
||||
const sim = await prisma.simCard.findUnique({
|
||||
where: { id: simCardId },
|
||||
select: { mobileDetails: { select: { contractId: true } } },
|
||||
});
|
||||
if (!sim?.mobileDetails) {
|
||||
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
|
||||
|
||||
const credentials = await contractService.getSimCardCredentials(simCardId);
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -285,9 +301,12 @@ export async function getSimCardCredentials(req: Request, res: Response): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
|
||||
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const credentials = await contractService.getInternetCredentials(contractId);
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -297,9 +316,21 @@ export async function getInternetCredentials(req: Request, res: Response): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
|
||||
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
|
||||
const phoneNumberId = parseInt(req.params.phoneNumberId);
|
||||
// PhoneNumber → InternetDetails → Contract
|
||||
const phone = await prisma.phoneNumber.findUnique({
|
||||
where: { id: phoneNumberId },
|
||||
select: { internetDetails: { select: { contractId: true } } },
|
||||
});
|
||||
if (!phone?.internetDetails) {
|
||||
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
|
||||
|
||||
const credentials = await contractService.getSipCredentials(phoneNumberId);
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -415,6 +446,8 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
||||
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const documents = await prisma.contractDocument.findMany({
|
||||
where: { contractId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
@@ -4,9 +4,10 @@ import * as customerService from '../services/customer.service.js';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { sanitizeCustomer, sanitizeCustomers, sanitizeCustomerStrict } from '../utils/sanitize.js';
|
||||
|
||||
// Customer CRUD
|
||||
export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { search, type, page, limit } = req.query;
|
||||
const result = await customerService.getAllCustomers({
|
||||
@@ -15,7 +16,12 @@ export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
});
|
||||
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
|
||||
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||
const sanitized = canSeePasswords
|
||||
? sanitizeCustomers(result.customers as any)
|
||||
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
||||
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -24,14 +30,19 @@ export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomer(req: Request, res: Response): Promise<void> {
|
||||
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||
if (!customer) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: customer } as ApiResponse);
|
||||
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
|
||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||
const sanitized = canSeePasswords
|
||||
? sanitizeCustomer(customer as any)
|
||||
: sanitizeCustomerStrict(customer as any);
|
||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as invoiceService from '../services/invoice.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
/**
|
||||
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||
@@ -146,9 +147,10 @@ export async function deleteInvoice(req: Request, res: Response): Promise<void>
|
||||
|
||||
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
||||
|
||||
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
|
||||
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
||||
res.json({ success: true, data: invoices } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -156,9 +158,10 @@ export async function getInvoicesByContract(req: Request, res: Response): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function addInvoiceByContract(req: Request, res: Response): Promise<void> {
|
||||
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { invoiceDate, invoiceType, notes } = req.body;
|
||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
|
||||
Reference in New Issue
Block a user