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
View File
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
fi fi
echo "[entrypoint] DB-Schema aktuell" echo "[entrypoint] DB-Schema aktuell"
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
# generate` am Start regeneriert den Client gegen das aktuelle Schema
# und kostet ~510 s tradeoff für Robustheit.
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen nicht kritisch, Client bleibt aus Image"
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden. # Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset). # RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e " USER_COUNT=$(node -e "
@@ -8,11 +8,12 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js'; import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js'; import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js'; import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml } from '../utils/sanitize.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js'; import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.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 { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import path from 'path'; import path from 'path';
@@ -1876,17 +1877,22 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
const filePath = path.join(uploadsDir, newFilename); const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`; const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, pdfBuffer); const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
const doc = await prisma.contractDocument.create({ // PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
data: { // Datei-Müll bei Race-Reject.
contractId: contract.id, const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
documentType, fs.writeFileSync(filePath, pdfBuffer);
documentPath: relativePath, return prisma.contractDocument.create({
originalName: `${email.subject || 'email'}.pdf`, data: {
notes: notes || null, contractId: contract.id,
uploadedBy: (req as any).user?.email || 'email-import', 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 // 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 filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`; const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content); const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
const doc = await prisma.contractDocument.create({ const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
data: { fs.writeFileSync(filePath, attachment.content);
contractId: contract.id, return prisma.contractDocument.create({
documentType, data: {
documentPath: relativePath, contractId: contract.id,
originalName: filename, documentType: cleanType,
notes: notes || null, documentPath: relativePath,
uploadedBy: (req as any).user?.email || 'email-import', originalName: filename,
}, notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
}); });
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer // 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 { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.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 { 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 * 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 documentPath = `/uploads/contract-documents/${req.file.filename}`;
const doc = await prisma.contractDocument.create({ const cleanType = stripHtml(documentType) as string;
data: { // Pentest 55.4: Race-Schutz Lock + Recent-Duplicate-Check.
contractId, const doc = await withContractDocumentLock(contractId, cleanType, () =>
documentType, prisma.contractDocument.create({
documentPath, data: {
originalName: req.file.originalname, contractId,
notes: notes || null, documentType: cleanType,
uploadedBy: req.user?.email, 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 } }); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({ await logChange({
+11
View File
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
return (downloadFile as any)(req, res, next); return (downloadFile as any)(req, res, next);
}); });
// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
// ohne /api-Präfix freischalten damit der Backend-Owner-Check immer
// läuft, egal wie nginx konfiguriert ist.
app.get('/uploads/*', authenticate as any, (req, res, next) => {
req.query.path = req.originalUrl.split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared // Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten // Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable // vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
@@ -84,6 +84,59 @@ export function startContractStatusScheduler(): void {
export { runExpireCheck }; export { runExpireCheck };
/**
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
* documentType) verhindert das in der Praxis (single-instance) und bietet
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
*
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
* legitimen Sekunden-später-Updates.
*/
const docCreateLocks = new Map<string, Promise<void>>();
export async function assertNoRecentDuplicateDocument(
contractId: number,
documentType: string,
): Promise<void> {
const recent = await prisma.contractDocument.findFirst({
where: {
contractId,
documentType,
createdAt: { gte: new Date(Date.now() - 10_000) },
},
select: { id: true },
});
if (recent) {
throw new Error('Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt bitte kurz warten und Seite neu laden.');
}
}
export async function withContractDocumentLock<T>(
contractId: number,
documentType: string,
fn: () => Promise<T>,
): Promise<T> {
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
const previous = docCreateLocks.get(key);
let release: () => void = () => {};
const slot = new Promise<void>((resolve) => { release = resolve; });
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
if (previous) await previous;
try {
await assertNoRecentDuplicateDocument(contractId, documentType);
return await fn();
} finally {
release();
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
docCreateLocks.delete(key);
}
}
}
/** /**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine * Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist: * Lieferbestätigung ist:
+23
View File
@@ -166,6 +166,29 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit * Provider-Passwort (nur über den dedizierten /password-Endpoint mit
* Audit-Log abrufbar) und sanitisiert das embedded customer. * Audit-Log abrufbar) und sanitisiert das embedded customer.
*/ */
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
// - Tags + gefährliche Schemata via stripHtml
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') return null;
const stripped = stripHtml(raw) as string;
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
// herausfiltern. Null/Form-Feed/Tabs raus.
const normalized = stripped
.replace(/\r\n?/g, '\n')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
const trimmed = normalized.trim();
if (trimmed === '') return null;
return trimmed.slice(0, maxLength);
}
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen // Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht, // Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen // dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen