fix: Rechteausweitung ueber Rollenzuweisung und weitere Auth-Haertung
Ein Sicherheitsaudit deckte auf, dass jede Rolle mit users:update sich selbst per PATCH /users/:id auf eine Admin-Rolle heraufstufen konnte, da roleId ohne eigene Berechtigung und ohne Selbstbezug-Pruefung akzeptiert wurde. Ausserdem lag ein fest im Code hinterlegtes Standard-Admin-Passwort vor, das bei fehlender SEED_ADMIN_PASSWORD-Env greift. - Neue Berechtigung users:assign-role, getrennt von users:create/update, fuer jede Rollenzuweisung erforderlich - Selbstaenderung der eigenen Rolle serverseitig blockiert (auch fuer Admins), ebenso Selbst-Reset der eigenen MFA ueber die Admin-Route - Seed erzeugt bei fehlender Env ein zufaelliges statt eines fixen Passworts - Wiederverwendung eines bereits rotierten Refresh-Tokens (Diebstahl- Indiz) widerruft nun alle Sessions des betroffenen Nutzers - Rate-Limit auf MFA-Enrollment-Bestaetigung ergaenzt Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
7bd2a3dc3a
commit
1b16b49913
7 files changed
+146
-7
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -55,6 +56,27 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigning a role is gated by its own permission, separate from
|
||||
* users:create/users:update - otherwise any role holding those (e.g. a
|
||||
* minimal "user manager" role) could hand out any role, including a
|
||||
* system/admin one, to itself or a colluding account. actingUserId is
|
||||
* only absent for non-HTTP callers (there are none today, and tests that
|
||||
* exercise the service directly without an actor), where there is no
|
||||
* caller to check permissions against.
|
||||
*/
|
||||
private async assertCanAssignRole(actingUserId?: string): Promise<void> {
|
||||
if (!actingUserId) {
|
||||
return;
|
||||
}
|
||||
const allowed = await this.permissionResolver.hasAny(actingUserId, [
|
||||
'users:assign-role',
|
||||
]);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Missing permission to assign roles');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertActiveRole(roleId: string): Promise<void> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
|
||||
if (!role) {
|
||||
@@ -127,6 +149,7 @@ export class UsersService {
|
||||
throw new ConflictException('Email already in use');
|
||||
}
|
||||
await this.assertActiveRole(dto.roleId);
|
||||
await this.assertCanAssignRole(actingUserId);
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@@ -158,6 +181,13 @@ export class UsersService {
|
||||
await this.assertActiveRole(dto.roleId);
|
||||
}
|
||||
if (roleChanged) {
|
||||
// Role changes for yourself always require a second party, regardless
|
||||
// of what permission the caller holds - otherwise anyone who can
|
||||
// reach this endpoint at all could grant themselves admin.
|
||||
if (actingUserId && id === actingUserId) {
|
||||
throw new ForbiddenException('Cannot change your own role');
|
||||
}
|
||||
await this.assertCanAssignRole(actingUserId);
|
||||
await this.assertNotLastSystemRoleUser(id);
|
||||
}
|
||||
const user = await this.prisma.user.update({
|
||||
@@ -202,8 +232,20 @@ export class UsersService {
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
/** Recovery path for a user who lost their MFA device (no email-reset flow exists in this project). */
|
||||
/**
|
||||
* Recovery path for a user who lost their MFA device (no email-reset flow
|
||||
* exists in this project). Deliberately blocked for the caller's own
|
||||
* account: unlike resetting someone else's MFA, self-service MFA removal
|
||||
* belongs behind a password check (POST /profile/mfa/disable) - otherwise
|
||||
* a stolen access token alone (no password) would be enough to strip the
|
||||
* victim's own MFA protection via this admin route.
|
||||
*/
|
||||
async resetMfa(id: string, actingUserId?: string): Promise<UserSummary> {
|
||||
if (actingUserId && id === actingUserId) {
|
||||
throw new ForbiddenException(
|
||||
'Use /profile/mfa/disable to disable your own MFA',
|
||||
);
|
||||
}
|
||||
await this.findById(id);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
|
||||
Reference in new issue
Block a user