Zum Inhalt springen

Manchmal sind Security Decorators nur Dekoration

Warum man Security Decorators in Code Reviews nicht blind vertrauen sollte

Autor

Dies ist eine übersetzte Version. Das englische Original finden Sie hier.

Anfang dieses Jahres habe ich mehrere Schwachstellen identifiziert, die mit der (unsicheren) Verwendung von Security Decorators zusammenhängen. Ich fand diese Beispiele interessant genug, dass ich sie gerne einer breiteren Öffentlichkeit zugänglich machen wollte…hier sind sie.

Einführung

Vereinfacht ausgedrückt ermöglichen Decorators Entwicklern, Metadaten zu einem Objekt, einer Methode oder einer Property hinzuzufügen, damit anderer Code darauf reagieren kann. Man kann sie sich als Hashtags für Code vorstellen. Ein einfaches Beispiel ist die Verwendung von Decorators zur Generierung der OpenAPI-/Swagger-Dokumentation einer API.

1@ApiTags('users')
2@Controller('users')
3export class UsersController {
4    @ApiOperation({ summary: 'List all users' })
5    @ApiResponse({ status: 200, type: [UserResponseDto], description: 'OK' })
6    @Get()
7    findAll(): PaginatedUsers {
8        return { data: [], total: 0 };
9  }

Decorators sollten nicht mit dem Decorator-Entwurfsmuster verwechselt werden. Decorators sind ein Sprachfeature von TypeScript / JavaScript. Ähnliche Features existieren auch in anderen Sprachen, zum Beispiel Java (Annotations) oder .NET und PHP (Attributes).

Im Security-Bereich verwendet man Decorators hauptsächlich für zwei Dinge:

  • Authentifizierung / Autorisierung (zum Beispiel um den Zugriff auf einen Controller oder eine Methode einzuschränken)
  • Eingabevalidierung (durch Hinzufügen von Typ- oder Musterinformationen)

Bei einem Code Review vertrauen Auditoren diesen Decorators oft blind und hinterfragen nicht, wie die Validierung im zugrundeliegenden Framework tatsächlich implementiert ist oder ob sie überhaupt geprüft wird. Je nach verwendeter Bibliothek und Anwendungskonfiguration kann das zu ernsthaften Problemen führen. Hier sind drei Beispiele, zwei für NestJS (TypeScript) und eines für Spring (Java):

Beispiel: class-validator

Die Bibliothek class-validator stellt Decorator-basierte Validierungsroutinen bereit, mit denen Objekt-Properties geprüft werden können. Der folgende Codeausschnitt ist eine stark gekürzte Version aus deren readme.md:

 1export class Post {
 2  @Length(10, 20)
 3  title: string;
 4
 5  @Contains('hello')
 6  text: string;
 7
 8  @IsInt()
 9  @Min(0)
10  @Max(10)
11  rating: number;
12
13  @IsEmail()
14  email: string;
15}

Standardmäßig rekursiert class-validator nicht in verschachtelte Objekte. Wenn die Bibliothek die Metadaten einer Klasse durchläuft, wird eine Property, die ein weiteres Objekt enthält, als Black-Box behandelt – es sei denn, der Decorator @ValidateNested() wird verwendet. Aus der Readme des Projekts:

If your object contains nested objects and you want the validator to perform their validation too, then you need to use the @ValidateNested() decorator.

Hier ein minimales Beispiel:

 1import { IsEmail, IsString, Length, validate } from 'class-validator';
 2
 3// Define the class address, using validators for the different properties
 4class Address {
 5  @IsString() @Length(1, 100) street: string;
 6  @IsString() country: string;
 7}
 8
 9class CreateUserDto {
10  @IsEmail() email: string;
11
12  // Holding an Address instance
13  // The validators in the Address class are not processed due to
14  // missing @ValidateNested()
15  address: Address;
16}

Der fehlende Decorator @ValidateNested() macht die Validatoren in der Address-Klasse defakto zu totem Code. Beispielsweise übersteht das folgende Payload die Validierung ohne Probleme (hier am Beispiel einer NoSQL-Injection):

1{
2  "email": "doesntmater@mogwailabs.de",
3  "address": { "street": 12345, "country": { "$ne": null }, "isAdmin": true }
4}

Das hat mehrere Gründe (zumindest meiner Annahme nach):

Die Typinformationen gehen verloren, wenn der TypeScript-Code nach JavaScript kompiliert wird. Zur Laufzeit kann class-validator daher nicht bestimmen, wo gegen validiert werden soll, sofern der Entwickler diese Informationen nicht auf anderem Weg bereitstellt. Ist die tatsächliche Klasse zur Laufzeit unbekannt, würde der Validator einfach nichts tun. Das ist schlechter, als einen expliziten Decorator zu verlangen.

Der andere Grund liegt im generellen Design des Validators. class-validator basiert auf der Idee, dass Entwickler Decorators hinzufügen, um eine Validierung zu erzwingen. Eine Property ohne Decorator wird dementsprechend auch nicht validiert. Das automatische Auswerten von Properties würde dieses Designmodell stören.

Hier daher eine sichere Version des vorherigen Beispiels. Sie verwendet die Bibliothek class-transformer, um die Typinformationen zu übergeben, und erzwingt die Validierung der Adresse durch @ValidateNested(). Es ist wichtig zu wissen, dass class-validator zusätzliche Features bietet (wie etwa das Blockieren unbekannter Properties), die hier nicht implementiert sind.

 1import { IsEmail, IsString, Length, validate } from 'class-validator';
 2
 3// Define the class address, using validators for the different properties
 4class Address {
 5  @IsString() @Length(1, 100) street: string;
 6  @IsString() country: string;
 7}
 8
 9class CreateUserDto {
10  @IsEmail() email: string;
11
12  @ValidateNested()
13  @Type(() => Address)
14  address: Address;
15}

Beispiel: Reihenfolge der Validierung in nest-keycloak-connect

Innerhalb von NestJS kann man eine grundlegende Autorisierung mit einem @Roles-Decorator und einem Custom Guard implementieren, der als Middleware läuft. In der Referenzimplementierung kann der Decorator auf der Klasse oder auf Methoden innerhalb der Klasse angewendet werden:

 1@Roles('user')          // class-level default
 2@Controller('users')
 3export class UsersController {
 4  @Get()
 5  findAll() {}           // requires a 'user' role (from the class decorator)
 6
 7  @Roles('admin', 'editor') // handler-level decorators
 8  @Post()
 9  create() {}            
10}

Wenn beide Varianten (Decorator auf Klassen- und Handlerebene) verwendet werden, muss bestimmt werden, welcher tatsächlich angewendet werden soll. NestJS bietet dafür zwei Strategien an:

  • getAllAndMerge – die auf Klassen- und Handler-Ebene definierten Rollen werden kombiniert, sodass der Nutzer alle Rollen erfüllen müsste.
  • getAllAndOverwrite – der Decorator auf Handler-Ebene ersetzt den auf Klassenebene. Das ist gut für ein Szenario „Klassen-Standard mit Handler-Ausnahme“ wie im vorherigen Beispiel, in dem nur Nutzer mit den Rollen „admin“ oder „editor“ neue Nutzer anlegen dürfen.

Diese Strategien sind in zwei reflector-Methoden implementiert. Im Fall von getAllAndOverwrite ist die Reihenfolge wichtig, in der die Objekte an diese Methode übergeben werden – und nicht besonders intuitiv.

Aus der NestJS-Dokumentation:

If your intent is to specify ‘user’ as the default role and override it selectively for certain methods, use the getAllAndOverride() method. It returns the first defined value, checking the targets in the order you pass them:

Hier die Implementierung role.guard.ts aus dem offiziellen NestJS-Beispiel.

 1@Injectable()
 2export class RolesGuard implements CanActivate {
 3  constructor(private reflector: Reflector) {}
 4
 5  canActivate(context: ExecutionContext): boolean {
 6    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
 7      context.getHandler(),
 8      context.getClass(),
 9    ]);
10    if (!requiredRoles) {
11      return true;
12    }
13    const { user } = context.switchToHttp().getRequest();
14    return requiredRoles.some((role) => user.roles?.includes(role));
15  }
16}

Wir haben kürzlich eine Anwendung gereviewed, die die NestJS-Bibliothek nest-keycloak-connect verwendet. Wie der Name schon andeutet, kann diese Bibliothek benutzt werden, um Keycloaks Authorization Service in eine NestJS-basierte Anwendung zu integrieren. Als Teil dieser Integration stellt nest-keycloak-connect einen eigenen Role Guard bereit.

Im 1.0-Branch findet sich der folgende Code. Beachte die Reihenfolge der Parameter, die innerhalb des Arrays übergeben werden:

57} else if (roleMerge == RoleMerge.OVERRIDE) {
58  const roleMetaData =
59    this.reflector.getAllAndOverride<RoleDecoratorOptionsInterface>(
60      META_ROLES,
61      [context.getClass(), context.getHandler()],
62    );

Dieser Role Guard vertauscht die Reihenfolge, wodurch die der Klasse zugewiesene Rolle die Rolle überschreibt, die dem Handler zugewiesen ist. In unserem vorherigen Beispiel könnten also alle Nutzer neue Nutzer anlegen, nicht nur Administratoren / Redakteure!

Ich vermute, dass der Autor der Bibliothek während der Entwicklung des Role Guards schlicht die Reihenfolge vertauscht hat. Die Reihenfolge wurde im Master-Branch korrigiert, bleibt aber (aus Kompatibilitätsgründen) im v1-Release bestehen.

Beispiel: Validierung der @Secured-Annotation in Spring Security

Die beiden vorherigen Beispiele zeigten Probleme in NestJS, das zugrundeliegende Problem findet sich auch in anderen Sprachen / Frameworks. So stellt Spring Security beispielsweise verschiedene Annotations bereit, die feingranulare Rechteprüfungen auf Methodenebene ermöglichen.

Eine der grundlegendsten Annotations ist @Secured, mit der sich der Zugriff auf Methoden auf einfache Rollen beschränken lässt. Hier Beispiele aus der offiziellen Dokumentation:

1@Secured({ "ROLE_USER" })
2public void create(Contact contact);
3
4@Secured({ "ROLE_USER", "ROLE_ADMIN" })
5public void update(Contact contact);
6
7@Secured({ "ROLE_ADMIN" })
8public void delete(Contact contact);

Um die Verwendung dieser Rollen durchzusetzen, verwendet man üblicherweise @EnableMethodSecurity (den Nachfolger von @EnableGlobalMethodSecurity). @EnableMethodSecurity verbindet mehrere Autorisierungs-Interceptoren, jedoch nur für die Annotationsgruppen, die explizit aktiviert wurden. Jedes Konfigurationsflag aktiviert einen anderen Satz von Annotations:

Config FlagDefaultActivates annotation processing for
prePostEnabledtrue@PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter
securedfalse@Secured
jsr250Enabledfalse@RolesAllowed, @PermitAll, @DenyAll

Enthält der Code also nur etwas wie den folgenden Beispielausschnitt, ist der Autorisierungs-Interceptor für die @Secured-Annotation nicht aktiviert und die Rollen werden folglich nicht durchgesetzt.

@Configuration
@EnableMethodSecurity(prePostEnabled = true)

Hier die korrekte Version, die den Interceptor lädt, um @Secured-Annotations durchzusetzen.

@Configuration
@EnableMethodSecurity(prePostEnabled = true, secured = true)

Findet mein LLM das?

Natürlich kann es das, wenn man danach fragt. Nehmen wir zum Beispiel den folgenden Prompt:

Provide me an overview of the existing controllers / handlers and the necessary access permissions to use them.

Basierend auf unserer Erfahrung wird das solche Schwachstellen in der Regel nicht aufdecken, da das LLM den Code selbst durchgeht und nicht die tatsächlichen Interceptors / die Middleware inspiziert, die für das Durchsetzen der Rechte verantwortlich sind. Je nach Setup ist der tatsächliche Code (der Bibliothek) unter Umständen auch gar nicht vorhanden. Wenn das LLM dieses Wissen nicht vortrainiert hat oder die verwendete Bibliothek nicht herunterladen kann, ist es möglich, dass ähnliche Schwachstellen wie die beschriebenen Beispielen übersehen werden.

Fragt man nach einer eingehenden Prüfung des Berechtigungssystems, werden diese Dinge normalerweise entdeckt.

Fazit

Wie ich eingangs angemerkt habe, fügen Decorators nur Metadaten zu einer Klasse oder Methode hinzu, nicht aber tatsächlichen Code. Bei der Prüfung von Code sollte man nicht blind davon ausgehen, dass sie wie beabsichtigt funktionieren. Nimm dir die Zeit, den Code zu prüfen und zu verstehen, der diese Metadaten tatsächlich verarbeitet.


Danke an Hannes Köttner auf Unsplash für das Titelbild.