Pentest 55.2 + 55.3 HIGH + 55.4 + 53.3: Notes/Document-Auth/Race/Generate

55.3 HIGH (Contract-Documents ohne Auth abrufbar):
- /uploads/contract-documents/*.pdf war HTTP 200 ohne Token, weil
  nginx die Datei direkt ausliefert und Backend nur /api/uploads/*
  schützte.
- Defense-in-Depth: app.get('/uploads/*') jetzt ebenfalls mit
  authenticate + downloadFile (Ownership-Check) abgesichert.
  Falls nginx fehlkonfiguriert sein sollte, fängt das Backend.

55.2 MEDIUM (notes ungestrippt + unlimitiert):
- Neuer sanitizeNotes-Helper: stripHtml + CRLF→LF + Control-Chars
  raus + Cap 2000 Zeichen. Eingesetzt für ContractDocument.notes
  in allen 3 Schreibpfaden (contract.controller, saveAttachment-
  AsContractDocument, saveEmailAsContractDocument).
- documentType zusätzlich stripHtml.

55.4 LOW (Race: 5x Lieferbestätigung → 5 Dokumente):
- Neuer In-Memory-Lock per (contractId, documentType) in
  contractStatusScheduler.service. withContractDocumentLock führt
  Recent-Duplicate-Check (10s-Window) + Write atomar aus.
- In cachedEmail-Pfaden: fs.writeFileSync ist jetzt INNERHALB des
  Locks → kein verwaister Datei-Müll bei Race-Reject.

53.3 (Prisma-Client veraltet bei ungebauten Images):
- docker-entrypoint.sh: `prisma generate` am Container-Start
  hinzugefügt. Kostet ~5–10 s, regeneriert den Client gegen das
  aktuelle Schema falls jemand ein Stale-Image hochgezogen hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:45:39 +02:00
parent da1934aa2d
commit 72de2f00f3
6 changed files with 143 additions and 35 deletions
@@ -8,11 +8,12 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml } from '../utils/sanitize.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
@@ -1876,17 +1877,22 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, pdfBuffer);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
// Datei-Müll bei Race-Reject.
const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
fs.writeFileSync(filePath, pdfBuffer);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType: cleanType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
@@ -2173,17 +2179,20 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
fs.writeFileSync(filePath, attachment.content);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType: cleanType,
documentPath: relativePath,
originalName: filename,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
+16 -12
View File
@@ -7,9 +7,9 @@ import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -727,16 +727,20 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
}
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
const doc = await prisma.contractDocument.create({
data: {
contractId,
documentType,
documentPath,
originalName: req.file.originalname,
notes: notes || null,
uploadedBy: req.user?.email,
},
});
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Race-Schutz Lock + Recent-Duplicate-Check.
const doc = await withContractDocumentLock(contractId, cleanType, () =>
prisma.contractDocument.create({
data: {
contractId,
documentType: cleanType,
documentPath,
originalName: req.file!.originalname,
notes: sanitizeNotes(notes),
uploadedBy: req.user?.email,
},
}),
);
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({