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:
2026-08-24 11:59:06 +02:00
co-authored by Claude Opus 5
parent 389fd30094
commit b3a9ef6372
4 changed files with 81 additions and 4 deletions
+47 -2
View File
@@ -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 {
+24
View File
@@ -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
+8
View File
@@ -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,
+2 -2
View File
@@ -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;
},