Pentest 62.7 LOW: deliveryDate / confirmationDate ISO-8601-Validierung
Bisher gingen XSS-Payloads in deliveryDate (saveEmailAsContractDocument, saveAttachmentAsContractDocument, uploadContractDocument) und confirmationDate (Cancellation-Confirmation-Upload) mit 200 durch. Das Datum wurde silent als null behandelt; Impact gering, aber schlechte API-Hygiene. Neuer validateOptionalIsoDate-Helper in utils/sanitize: - ISO-8601-Regex YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS(.fff)?(Z|+HH:MM)? - null / leerer String / undefined sind OK (Optional-Semantik) - Sonstige Eingaben werfen 400 mit klarer Meldung Eingesetzt in: - contract.controller uploadContractDocument (multer-Datei wird bei Reject sauber gelöscht) - cachedEmail.controller saveEmailAsContractDocument + saveAttachmentAsContractDocument: Validierung früh, BEVOR Dateien geschrieben werden – kein Datei-Müll bei Reject - upload.routes handleContractDocumentUpload (cancellationConfirmation*) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ 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, validateContractDocumentType } from '../utils/sanitize.js';
|
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate } 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';
|
||||||
@@ -1836,6 +1836,15 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||||
@@ -1898,8 +1907,8 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
@@ -2096,6 +2105,15 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||||
@@ -2201,8 +2219,8 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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, sanitizeNotes, validateContractDocumentType } from '../utils/sanitize.js';
|
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||||
|
|
||||||
@@ -722,7 +722,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { documentType, notes, deliveryDate } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||||
@@ -734,6 +734,16 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
||||||
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
||||||
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
canAccessBankCard,
|
canAccessBankCard,
|
||||||
canAccessIdentityDocument,
|
canAccessIdentityDocument,
|
||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
||||||
@@ -738,11 +739,18 @@ async function handleContractDocumentUpload(
|
|||||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
const dateField = fieldName === 'cancellationConfirmationPath'
|
||||||
? 'cancellationConfirmationDate'
|
? 'cancellationConfirmationDate'
|
||||||
: 'cancellationConfirmationOptionsDate';
|
: 'cancellationConfirmationOptionsDate';
|
||||||
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
|
// Pentest 62.7: confirmationDate gegen ISO-8601 validieren.
|
||||||
|
let provided: string | null;
|
||||||
|
try {
|
||||||
|
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
|
||||||
|
} catch (err) {
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let target: Date | null = null;
|
let target: Date | null = null;
|
||||||
if (provided) {
|
if (provided) {
|
||||||
const parsed = new Date(provided);
|
target = new Date(provided);
|
||||||
if (!isNaN(parsed.getTime())) target = parsed;
|
|
||||||
}
|
}
|
||||||
if (target) {
|
if (target) {
|
||||||
updateData[dateField] = target;
|
updateData[dateField] = target;
|
||||||
|
|||||||
@@ -246,6 +246,33 @@ export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | u
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
|
||||||
|
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
|
||||||
|
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
|
||||||
|
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
|
||||||
|
// soll 400 zurückgeben, nicht 200.
|
||||||
|
//
|
||||||
|
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
|
||||||
|
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
|
||||||
|
// sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt".
|
||||||
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
||||||
|
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||||||
|
throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
const NOTES_DEFAULT_MAX = 2000;
|
const NOTES_DEFAULT_MAX = 2000;
|
||||||
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user