Dienstkonto-Kennzeichen gegatet und laut protokolliert (Pentest R184)
Mit dem Scharfschalten des Feldes wurde ein alarm-senkendes Attribut ueber den normalen Benutzer-Update-Pfad setzbar - dieselbe Klasse wie R183-01, nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als Routine erscheinen. Alle drei Sorgen des Pentesters bestaetigt: kein Gate, Protokollierung nur als MEDIUM (Standardstufe fuer User), und jeder mit users:update konnte es auf jedes Konto setzen, auch auf das eigene. Fix analog zur Retention-Absenkung: Bestaetigung confirm SERVICE_ACCOUNT beim Aktivieren; CRITICAL statt MEDIUM mit eigenem Label und Wer/Vorher/Nachher; kein Selbstbedienen (403 am eigenen Konto, muss ein anderer Administrator vornehmen). Das Frontend sendet die Bestaetigung mit - der Haken im Formular ist die Bestaetigung. Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200, am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -99,7 +99,7 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
// `permissions` und `password` darf der generische Update nicht
|
||||
@@ -164,6 +164,41 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
: null;
|
||||
|
||||
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
|
||||
// Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung,
|
||||
// und kein Selbstbedienen.
|
||||
const setztDienstkonto =
|
||||
typeof (data as any).isServiceAccount === 'boolean' &&
|
||||
(data as any).isServiceAccount !== before?.isServiceAccount;
|
||||
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
|
||||
|
||||
if (setztDienstkonto && req.user?.userId === userId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
|
||||
'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' +
|
||||
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
|
||||
'Administrator vornehmen lassen.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (aktiviertDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
`Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im Audit-Log ` +
|
||||
'als Routine geführt statt als kritisches Ereignis. Das ist für planmäßig arbeitende ' +
|
||||
'Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ein Mensch benutzt, wäre es ' +
|
||||
'eine Tarnung. Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.updateUser(userId, data as any);
|
||||
if (user) {
|
||||
// Audit: Geänderte Felder ermitteln und loggen
|
||||
@@ -172,6 +207,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
const fieldLabels: Record<string, string> = {
|
||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||
};
|
||||
for (const [key, newVal] of Object.entries(data)) {
|
||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||
@@ -191,7 +227,16 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'User',
|
||||
resourceId: user.id.toString(),
|
||||
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
label: setztDienstkonto
|
||||
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
|
||||
`${user.email} – Anmeldungen werden künftig ` +
|
||||
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
|
||||
: changeList
|
||||
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
|
||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||
sensitivity: setztDienstkonto ? 'CRITICAL' : undefined,
|
||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24)
|
||||
- Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes
|
||||
bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den
|
||||
normalen Benutzer-Update-Pfad setzbar – dieselbe Klasse wie R183-01,
|
||||
nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen
|
||||
auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche.
|
||||
- Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate,
|
||||
Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit
|
||||
`users:update` konnte es auf jedes Konto setzen – auch auf das eigene.
|
||||
- Fix, analog zur Retention-Absenkung:
|
||||
* **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit
|
||||
Klartext, was das bedeutet.
|
||||
* **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen
|
||||
GESETZT fuer … – Anmeldungen werden kuenftig als Routine gefuehrt“) sowie
|
||||
Wer/Vorher/Nachher.
|
||||
* **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder
|
||||
setzbar noch entfernbar (403 mit Begruendung) – muss ein anderer
|
||||
Administrator vornehmen.
|
||||
- Frontend sendet die Bestaetigung mit; der Haken im Formular IST die
|
||||
Bestaetigung, der Betreiber merkt nichts davon.
|
||||
- Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200,
|
||||
am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
|
||||
`npm run build` (inkl. `tsc`) und Backend-`tsc` gruen.
|
||||
|
||||
- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
|
||||
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
|
||||
aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
|
||||
|
||||
@@ -328,6 +328,10 @@ function UserModal({
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
hasGdprAccess: formData.hasGdprAccess,
|
||||
isServiceAccount: formData.isServiceAccount,
|
||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||
// im Formular IST diese Bestätigung.
|
||||
...(formData.isServiceAccount ? { confirm: 'SERVICE_ACCOUNT' } : {}),
|
||||
whatsappNumber: formData.whatsappNumber || undefined,
|
||||
telegramUsername: formData.telegramUsername || undefined,
|
||||
signalNumber: formData.signalNumber || undefined,
|
||||
@@ -364,6 +368,10 @@ function UserModal({
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
hasGdprAccess: formData.hasGdprAccess,
|
||||
isServiceAccount: formData.isServiceAccount,
|
||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||
// im Formular IST diese Bestätigung.
|
||||
...(formData.isServiceAccount ? { confirm: 'SERVICE_ACCOUNT' } : {}),
|
||||
whatsappNumber: formData.whatsappNumber || undefined,
|
||||
telegramUsername: formData.telegramUsername || undefined,
|
||||
signalNumber: formData.signalNumber || undefined,
|
||||
|
||||
@@ -1548,11 +1548,11 @@ export const userApi = {
|
||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; isServiceAccount?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||
return res.data;
|
||||
},
|
||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[] }) => {
|
||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; confirm?: string }) => {
|
||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user