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
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.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e "
@@ -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({
+11
View File
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate 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
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
@@ -84,6 +84,59 @@ export function startContractStatusScheduler(): void {
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
* 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
* 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
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen