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:
@@ -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 ~5–10 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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user