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 {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// `permissions` und `password` darf der generische Update nicht
|
// `permissions` und `password` darf der generische Update nicht
|
||||||
@@ -164,6 +164,41 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
: null;
|
: 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);
|
const user = await userService.updateUser(userId, data as any);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// 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> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
@@ -191,7 +227,16 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
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,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
|
||||||
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
|
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
|
||||||
aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
|
aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
|
||||||
|
|||||||
@@ -328,6 +328,10 @@ function UserModal({
|
|||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
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,
|
whatsappNumber: formData.whatsappNumber || undefined,
|
||||||
telegramUsername: formData.telegramUsername || undefined,
|
telegramUsername: formData.telegramUsername || undefined,
|
||||||
signalNumber: formData.signalNumber || undefined,
|
signalNumber: formData.signalNumber || undefined,
|
||||||
@@ -364,6 +368,10 @@ function UserModal({
|
|||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
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,
|
whatsappNumber: formData.whatsappNumber || undefined,
|
||||||
telegramUsername: formData.telegramUsername || undefined,
|
telegramUsername: formData.telegramUsername || undefined,
|
||||||
signalNumber: formData.signalNumber || undefined,
|
signalNumber: formData.signalNumber || undefined,
|
||||||
|
|||||||
@@ -1548,11 +1548,11 @@ export const userApi = {
|
|||||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
return res.data;
|
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);
|
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||||
return res.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);
|
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user