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`;
+29
View File
@@ -97,6 +97,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **📤 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26)
- Der Tester fand: `GET /audit-logs/export?userId=…` filterte **nicht**
`userId=999999` gab alle 2761 Datensätze zurück, byte-identisch zum
ungefilterten Export. HTTP 200, sah korrekt aus.
- **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte:
der Export-Controller pflegte eine **eigene, kürzere** Filterliste und
verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`,
`success` **und** `search`. Der Service konnte alle sechs sie kamen
nie bei ihm an.
- **Dieselbe Lücke ein drittes Mal in der Oberfläche:** Der CSV-Knopf baute
seine Parameter nochmal von Hand, mit wieder anderen fünf 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" für eine DSGVO-Auskunft oder eine
Innentäter-Prüfung gab das vollständige Protokoll **aller** Nutzer
heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen
Pfad ist das eine Weitergabe, kein Schönheitsfehler.
- **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)`
für Liste und Export; die Oberfläche schickt alle aktiven Filter statt
einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen,
laufen früher oder später auseinander jetzt gibt es nur noch eine.
- Geprüft über HTTP: Export und Liste liefern für `userId`, `action`,
`search`, `success`, `resourceType` **identische** Treffermengen; auf dem
Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt
200). CSV-Pfad gegengeprüft.
- Dateien: `backend/src/controllers/auditLog.controller.ts`,
`frontend/src/pages/settings/AuditLogs.tsx`
- [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26)
- **R185-01 (MEDIUM)** Die Flanke, die wir dem Tester selbst gemeldet
hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
+10 -5
View File
@@ -308,11 +308,16 @@ export default function AuditLogs() {
const downloadToken = await authApi.getDownloadToken();
const params = new URLSearchParams();
params.set('format', 'csv');
if (filters.action) params.set('action', filters.action);
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters.resourceType) params.set('resourceType', filters.resourceType);
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
// nur fuenf wer im Suchfeld eingrenzte und dann CSV klickte, bekam
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
// vollstaendig ueber die gefilterte Menge.
for (const [schluessel, wert] of Object.entries(filters)) {
if (schluessel === 'page' || schluessel === 'limit') continue;
if (wert === undefined || wert === null || wert === '') continue;
params.set(schluessel, String(wert));
}
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
} else {
const result = await auditLogApi.export({ ...filters, format });