Security-Hardening Runde 12: Information-Disclosure + Input-Validation

Pentest Runde 7 (Anschlussrunde):

MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
  portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
  privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
  portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
  abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
  passende Variante. Mitarbeiter sehen commission/notes weiterhin.

LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.

INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.

Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
  portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
  nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
  portalPasswordEncrypted weg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 08:51:52 +02:00
parent c744eebfa3
commit 28c91759df
4 changed files with 147 additions and 4 deletions
+13 -2
View File
@@ -6,6 +6,7 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
@@ -46,9 +47,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractsStrict(result.contracts as any[])
: sanitizeContracts(result.contracts as any[]);
res.json({
success: true,
data: result.contracts,
data,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
@@ -89,7 +96,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
res.json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
+19
View File
@@ -262,6 +262,25 @@ app.use('/api', (_req, res, next) => {
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik: jedes Segment, das mit einer Ziffer
// beginnt aber nicht aus reinen Ziffern besteht (`6abc`, `12foo`), wird als
// Tippfehler/Manipulation behandelt.
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && /^\d/.test(seg) && !/^\d+$/.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
+80 -2
View File
@@ -17,6 +17,37 @@ const SENSITIVE_CUSTOMER_FIELDS = [
'consentHash',
] as const;
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
// sollen Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
// unnötige Informationsleckage über den DB-Aufbau.
// Pentest Runde 7 (2026-05-17), MEDIUM.
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
'portalTokenInvalidatedAt',
'portalLastLogin',
'portalPasswordMustChange',
'lastBirthdayGreetingYear',
// privacyPolicyPath etc. sind interne Datei-Pfade Portal nutzt
// dedizierte PDF-Endpoints, nicht den Pfad direkt
'privacyPolicyPath',
'businessRegistrationPath',
'commercialRegisterPath',
] as const;
// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
// (mit Audit-Log) abrufbar im /contracts/:id selbst nutzlos.
const SENSITIVE_CONTRACT_FIELDS = [
'portalPasswordEncrypted',
] as const;
// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
'commission',
'notes',
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
] as const;
const SENSITIVE_USER_FIELDS = [
'password',
'passwordResetToken',
@@ -43,14 +74,18 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
* Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
* zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
* Admin ist (z.B. Portal-Kunde).
*/
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
if (!copy) return null;
delete copy.portalPasswordEncrypted;
for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
delete copy[field];
}
return copy as T;
}
@@ -61,6 +96,49 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
* Audit-Log abrufbar) und sanitisiert das embedded customer.
*/
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy: Record<string, unknown> = { ...contract };
for (const field of SENSITIVE_CONTRACT_FIELDS) {
delete copy[field];
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
}
return copy as T;
}
/**
* Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
* (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
* mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
*/
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy = sanitizeContract(contract) as Record<string, unknown> | null;
if (!copy) return null;
for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
delete copy[field];
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
}
return copy as T;
}
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
}
export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize User-Objekt für API-Responses.
*/