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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user