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:
@@ -64,43 +64,50 @@ function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | unde
|
|||||||
return wert;
|
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
|
* Audit-Logs mit Filtern abrufen
|
||||||
*/
|
*/
|
||||||
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
userId,
|
|
||||||
customerId,
|
|
||||||
dataSubjectId,
|
|
||||||
action,
|
|
||||||
sensitivity,
|
|
||||||
resourceType,
|
|
||||||
resourceId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
success,
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const result = await auditService.searchAuditLogs({
|
const result = await auditService.searchAuditLogs({
|
||||||
userId: pruefeZahl(userId, 'userId'),
|
...leseFilter(req),
|
||||||
customerId: pruefeZahl(customerId, 'customerId'),
|
page: pruefeZahl(req.query.page, 'page') || 1,
|
||||||
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,
|
|
||||||
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
||||||
// Zug ziehen, an der Seitenlogik vorbei.
|
// 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 });
|
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) {
|
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
||||||
const {
|
const content = await auditService.exportAuditLogs(leseFilter(req), format);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
|||||||
@@ -97,6 +97,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [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
|
- **R185-01 (MEDIUM)** – Die Flanke, die wir dem Tester selbst gemeldet
|
||||||
hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
|
hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
|
||||||
|
|||||||
@@ -308,11 +308,16 @@ export default function AuditLogs() {
|
|||||||
const downloadToken = await authApi.getDownloadToken();
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('format', 'csv');
|
params.set('format', 'csv');
|
||||||
if (filters.action) params.set('action', filters.action);
|
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
|
||||||
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
|
// nur fuenf – wer im Suchfeld eingrenzte und dann CSV klickte, bekam
|
||||||
if (filters.resourceType) params.set('resourceType', filters.resourceType);
|
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
|
||||||
if (filters.startDate) params.set('startDate', filters.startDate);
|
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
|
||||||
if (filters.endDate) params.set('endDate', filters.endDate);
|
// 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');
|
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const result = await auditLogApi.export({ ...filters, format });
|
const result = await auditLogApi.export({ ...filters, format });
|
||||||
|
|||||||
Reference in New Issue
Block a user