security: portalPasswordHash + Encrypted aus embedded customer in /contracts/* entfernen

Folgefix zum CRITICAL-IDOR auf Stressfrei-Sub-Routes: der separate
/customers/:id-Endpoint sanitizt seinen Output schon, aber GET /contracts/:id
embeddete weiterhin das volle Customer-Objekt inkl.
- portalPasswordHash (bcrypt-Hash des Portal-Login-Passworts)
- portalPasswordEncrypted (AES-256-GCM des Klartext-Passworts)
- portalPasswordResetToken (langlebiger 1-time-Token)

Zwei Lecks im contract.service:
- getContractById hatte `customer: true` ohne Sanitize
- createContract hatte dasselbe Muster

Beide jetzt mit sanitizeCustomerStrict() nach dem Load. Der Helper war schon
im utils/sanitize.ts vorhanden – wurde nur nicht aufgerufen.

Live-verifiziert: GET /api/contracts/1 → embedded customer enthält 30 saubere
Felder, KEIN portalPasswordHash/Encrypted/ResetToken mehr.

Weitere `customer: true`-Stellen geprüft und freigegeben:
- pdfTemplate.service.generateFilledPdf: nur internal, gibt PDF-Buffer zurück
- cachedEmail.controller.saveEmailAsPdf: nur internal für File-Ops
- getAllContracts: schon mit explizitem Select (5 sichere Felder)
- updateContract: kein customer-Include

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:06:01 +02:00
parent 08310ac302
commit 92d2e62e79
+22 -1
View File
@@ -2,6 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
export interface ContractFilters {
customerId?: number;
@@ -154,7 +155,18 @@ export async function getContractById(id: number, decryptPassword = false) {
if (!contract) return null;
// Decrypt password if requested and exists
// SECURITY: Embedded Customer-Objekt sanitizen, sonst leaken
// portalPasswordHash + portalPasswordEncrypted + Reset-Token in jede
// contract.customer-Response. Der direkte `/customers/:id`-Endpoint hat
// den Schutz schon; hier wäre er ohne Sanitize bypassbar.
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
// Decrypt password if requested and exists (Contract-Anbieter-Passwort,
// nicht zu verwechseln mit Customer-Portal-Passwort)
if (decryptPassword && contract.portalPasswordEncrypted) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
@@ -385,6 +397,15 @@ export async function createContract(data: ContractCreateData) {
},
});
// Embedded Customer-Objekt sanitizen (siehe getContractById derselbe
// Schutz; createContract gibt den frisch erstellten Vertrag inkl. Customer
// zurück, und der darf keine Passwort-Hashes/-Encryptions leaken).
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
return contract;
}