Pentest R86: Vertrags-Identifier max 100 + Charset-Whitelist

R86.1 LOW + R86.2 LOW: >999-Zeichen liefen in DB-Overflow (500
statt 400), Attribut-Injection (`foo" onerror=…` ohne
umschließenden Tag) überlebte stripHtml.

Fix: validateContractIdentifier() (max 100,
^[A-Za-z0-9_\-/. ]{0,100}$) in sanitize.ts, eingehängt in
sanitizeContractBody. Wirft ApiError(400, …). Literales Space
statt \s → kein CRLF/Tab → kein Header-Injection-Vektor in
CSV-/Mail-/PDF-Export. Greift auf alle fünf Identifier-Felder
(Provider + Sales-Platform). ContractForm-Inputs bekommen
maxLength={100} als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:14:00 +02:00
parent 0b7bb89ebc
commit c8b86ca9a7
5 changed files with 115 additions and 7 deletions
@@ -8,7 +8,7 @@ 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, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
@@ -36,7 +36,14 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') {
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
return stripHtml(body);
const stripped = stripHtml(body);
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
if (parentKey && isContractIdentifierField(parentKey)) {
return validateContractIdentifier(stripped, parentKey);
}
return stripped;
}
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
if (typeof body === 'object') {
+53
View File
@@ -343,6 +343,59 @@ export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): strin
return trimmed;
}
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
// in PDF-/Mail-Export potentiell aktiv werden könnten.
//
// Whitelist orientiert sich am Vorschlag des Pentesters
// `^[\w\-\/\s\.]*$` Whitespace ist hier bewusst NUR ein literales
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
// Limit der DB-Spalte.
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
]);
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
export function isContractIdentifierField(key: string): boolean {
return CONTRACT_IDENTIFIER_FIELDS.has(key);
}
export function validateContractIdentifier(
raw: unknown,
fieldLabel: string,
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
);
}
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
);
}
return trimmed;
}
const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null;