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:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 08:57:42 +02:00
1 parent 7bd2a3dc3a
commit 1b16b49913
7 files changed
+146 -7

No files matched your search

@@ -1,4 +1,8 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -22,6 +26,7 @@ describe('UsersService', () => {
let permissionResolver: {
invalidateUser: jest.Mock;
invalidateAll: jest.Mock;
hasAny: jest.Mock;
};
const staffRole = {
@@ -77,6 +82,7 @@ describe('UsersService', () => {
permissionResolver = {
invalidateUser: jest.fn(),
invalidateAll: jest.fn(),
hasAny: jest.fn().mockResolvedValue(true),
};
usersService = new UsersService(
prisma as unknown as PrismaService,
@@ -324,4 +330,53 @@ describe('UsersService', () => {
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('update throws ForbiddenException when the caller tries to change their own role', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
await expect(
usersService.update(adminUser.id, { roleId: staffRole.id }, adminUser.id),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('update throws ForbiddenException when the caller lacks users:assign-role', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.role.findUnique.mockResolvedValue(adminRole);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
usersService.update(
existingUser.id,
{ roleId: adminRole.id },
adminUser.id,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('create throws ForbiddenException when the caller lacks users:assign-role', async () => {
prisma.user.findUnique.mockResolvedValue(null);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
usersService.create(
{
email: '[email protected]',
password: 'password123',
name: 'New User',
roleId: staffRole.id,
},
adminUser.id,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('resetMfa throws ForbiddenException when targeting your own account', async () => {
await expect(
usersService.resetMfa(adminUser.id, adminUser.id),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+43 -1
View File
@@ -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 },