Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)

Der Tester fand: GET /audit-logs/export?userId=… filterte nicht.
userId=999999 gab alle 2761 Datensaetze zurueck, byte-identisch zum
ungefilterten Export. HTTP 200, sah korrekt aus.

Die Ursache war breiter als der Befund. Nicht userId allein fehlte: der
Export-Controller pflegte eine eigene, kuerzere Filterliste und verwarf
still userId, customerId, dataSubjectId, resourceId, success UND search.
Der Service konnte alle sechs - sie kamen nie bei ihm an. Dieselbe Luecke
ein drittes Mal in der Oberflaeche: der CSV-Knopf baute seine Parameter
nochmal von Hand, mit wieder anderen fuenf Feldern. Wer im Suchfeld
eingrenzte und dann CSV klickte, bekam das gesamte Protokoll statt seiner
Auswahl.

Warum das mehr ist als ein fehlender Filter: Ein bewusst eingegrenzter
Export - "nur die Spur von Benutzer X" fuer eine DSGVO-Auskunft oder eine
Innentaeter-Pruefung - gab das vollstaendige Protokoll aller Nutzer
heraus, mit einem beruhigenden 200. Auf einem datenminimierungs-
pflichtigen Pfad ist das eine Weitergabe, kein Schoenheitsfehler.

Der Fix ist strukturell: ein gemeinsamer leseFilter(req) fuer Liste und
Export, und die Oberflaeche schickt alle aktiven Filter statt einer
handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen, laufen
frueher oder spaeter auseinander; jetzt gibt es nur noch eine.

Geprueft ueber HTTP gegen eine Wegwerf-DB: Export und Liste liefern fuer
userId, action, search, success und resourceType identische
Treffermengen; auf dem Export-Pfad gilt jetzt dieselbe Validierung
(userId=abc -> 400 statt 200). CSV-Pfad gegengeprueft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 11:46:26 +02:00
co-authored by Claude Opus 5
parent e81a83ae8f
commit ef2411ebe4
3 changed files with 76 additions and 52 deletions
+37 -47
View File
@@ -64,43 +64,50 @@ function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | unde
return wert;
}
/**
* Filter aus der Query lesen EINE Stelle fuer Liste und Export (R186-01).
*
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
* Export „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
* Innentaeter-Pruefung lieferte damit das VOLLSTAENDIGE Protokoll aller
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
*
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
*/
function leseFilter(req: AuthRequest) {
const q = req.query;
return {
userId: pruefeZahl(q.userId, 'userId'),
customerId: pruefeZahl(q.customerId, 'customerId'),
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
startDate: pruefeDatum(q.startDate, 'startDate'),
endDate: pruefeDatum(q.endDate, 'endDate'),
success: q.success !== undefined ? q.success === 'true' : undefined,
search: pruefeText(q.search, 'search'),
};
}
/**
* Audit-Logs mit Filtern abrufen
*/
export async function getAuditLogs(req: AuthRequest, res: Response) {
try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({
userId: pruefeZahl(userId, 'userId'),
customerId: pruefeZahl(customerId, 'customerId'),
dataSubjectId: pruefeZahl(dataSubjectId, 'dataSubjectId'),
action: pruefeEnum<AuditAction>(action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(resourceType, 'resourceType', 100),
resourceId: pruefeText(resourceId, 'resourceId', 100),
startDate: pruefeDatum(startDate, 'startDate'),
endDate: pruefeDatum(endDate, 'endDate'),
success: success !== undefined ? success === 'true' : undefined,
search: pruefeText(search, 'search'),
page: pruefeZahl(page, 'page') || 1,
...leseFilter(req),
page: pruefeZahl(req.query.page, 'page') || 1,
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
// Zug ziehen, an der Seitenlogik vorbei.
limit: Math.min(pruefeZahl(limit, 'limit') || 50, 200),
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
});
res.json({ success: true, ...result });
@@ -163,24 +170,7 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
export async function exportAuditLogs(req: AuthRequest, res: Response) {
try {
const format = req.query.format === 'csv' ? 'csv' : 'json';
const {
action,
sensitivity,
resourceType,
startDate,
endDate,
} = req.query;
const content = await auditService.exportAuditLogs(
{
action: pruefeEnum<AuditAction>(action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(resourceType, 'resourceType', 100),
startDate: pruefeDatum(startDate, 'startDate'),
endDate: pruefeDatum(endDate, 'endDate'),
},
format
);
const content = await auditService.exportAuditLogs(leseFilter(req), format);
if (format === 'csv') {
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;