28. August 2024 von Jannis Kaiser
Fehlermeldungen sicher ans Frontend bringen mit Spring-Boot
Seit Version 2.3 verbirgt das Spring Boot Framework standardmäßig genauere Details zu Fehlern, die beim Bearbeiten einer Anfrage auftreten. Dies betrifft unter anderem die Meldungen von Exceptions sowie die Meldungen, die in den Validierungs-Annotations angegeben werden. Häufig ist es in einem Projekt jedoch notwendig, Details über den Fehler im Frontend zur Verfügung zu haben.
Beispielsweise ist es keine gute User Experience, wenn bei einer 400-Bad-Request-Antwort vom Backend eine generische Fehlermeldung “Diese Anfrage konnte nicht erfolgreich verarbeitet werden” angezeigt wird. Hier sollte das Frontend anhand des Fehlers unterscheiden, ob der Fehler aufgetreten ist, weil der Registrierungslink abgelaufen ist oder weil der Benutzername bereits vergeben ist. Dazu benötigt es aber die entsprechenden Details zum Fehler.
In diesem Blog-Beitrag gehe ich darauf ein, warum Fehlerdetails sicherheitsrelevant sein können und wie man mit Spring Boot trotzdem die notwendigen Informationen zu den Nutzenden bringen kann.
Warum werden Fehlerdetails standardmäßig versteckt
Bis zur Spring Boot Version 2.3 wurden Details über den Fehler wie die message
der Exception oder der Validierungs-Annotation immer in der Antwort mitgeliefert. Die Änderung hat das Spring-Boot-Projekt mit gutem Grund umgesetzt. Zuvor wurden diese Details bei allen Exceptions mit in die Fehlerantwort aufgenommen.
Bei Fehlern, die tiefer in der Anwendung auftreten, stellt das jedoch eine Gefahr für die Sicherheit dar. Denn eine detaillierte Fehlermeldung über eine fehlgeschlagene SQL-Abfrage oder einen fehlgeschlagenen Aufruf an ein Drittsystem kann von einem Angreifer beispielsweise genutzt werden, um Implementierungsdetails über die Anwendung oder sogar Daten von Kundinnen und Kunden zu extrahieren.
Die Lösung wäre nun, bei technischen oder unerwarteten Fehlern die Details zu verbergen und nur bei bestimmten technischen Fehlern genügend Informationen zu liefern, um die Fehlerfälle unterscheiden zu können.
Unsicheres aktivieren von Fehlermeldungen
Über die Konfigurationen server.error.include-message=never|on-param|always
und server.error.include-binding-errors=never|on-param|always
können diese Details auch heute noch wieder aktiviert werden. Sowohl der Wert always
als auch on-param
bergen jedoch die oben genannte Gefahr und sollten daher nicht verwendet werden.
always
schreibt die Details immer in eine Fehlerantwort. on-param
schreibt die Details in die Fehlerantwort, wenn die Anfrage den Parameter message=true
enthält. Da die Anfrage aber auch vom Angreifenden kontrolliert wird, hilft das der Sicherheit nicht.
Mit aktiviertem include-message
hat eine Fehlerantwort das folgende Format:
{
"timestamp": "2024-08-07T19:07:36.173+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "Malformed SQL query",
"path": "/"
}
Ohne include-message
bleibt dasselbe Format erhalten, allerdings ohne den Eintrag message
. Die oben genannten Konfigurationen sollten also auf dem Standardwert never
belassen werden.
Fachliche Exceptions definieren
Ein bewährter und von Spring gut unterstützter Ansatz ist es, für jeden fachlichem Fehlerfall, eine Exception-Klasse zu definieren. Diese Exception kann dann mit der Spring-Annotation @ResponseStatus versehen werden, um automatisch in eine Fehlerantwort mit dem entsprechende Http-Status umgewandelt zu werden, wenn sie auftritt.
@ResponseStatus(value = HttpStatus.BAD_REQUEST,
reason = "DUPLICATE_USERNAME")
public class DuplicateUsernameException extends RuntimeException {}
Der reason
-Parameter ist der Parameter, der später als message in der Antwort erscheinen soll. Hier wird er auf den vom Frontend interpretierbaren Wert DUPLICATE_USERNAE
gesetzt.
Wie kann die Fehlerantwort nun bei fachlichen Fehlern um die nötigen Details ergänzt werden?
Mögliche Lösung mit einem ControllerAdvice
Mit einem ControllerAdvice ControllerAdvic
e bietet Spring die Möglichkeit, die Antwort für ausgewählte Fehler selbst zu gestalten. Für den technischen Fehler, dass ein Benutzername bereits vergeben ist, könnte dies beispielsweise wie folgt aussehen:
@ControllerAdvice
public class DuplicateUsernameExceptionHandler
extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = { DuplicateUsernameException.class })
protected ResponseEntity<CustomErrorModel> handleDuplicateUsername(
DuplicateUsernameException ex, WebRequest request) {
var errorModel = CustomErrorModel.forDuplicateUser(ex.getUsername());
return handleExceptionInternal(
ex, errorModel, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
}
Dieser Ansatz erfordert jedoch, dass ein selbstdefiniertes Datenmodell für die Fehlerreaktion definiert wird. Fehler, die auf diese Weise behandelt werden, haben dann das selbst definierte Format, während alle anderen Fehler, die in der Anwendung auftreten können, weiterhin dem Standardformat von Spring entsprechen.
Der Ansatz mit einem ControllerAdvice
ist daher nur dann geeignet, wenn die API es erfordert, dass für alle Fehlerantworten ein eigenes Format zurückgegeben wird. Dies ist in der Regel nur bei sehr großen Projekten oder extern angebotenen APIs notwendig.
Für viele Projekte wäre es besser, dem Standardformat von Spring zu folgen und trotzdem nur bei ausgewählten Fehlern erweiterte Details mit zurückzugeben.
Standardformat, aber trotzdem die Kontrolle?
Spring bietet mit der ErrorAttributes-Bean
die Möglichkeit, für jede Anfrage, bei der ein Fehler aufgetreten ist, zu entscheiden, welche der verfügbaren Details zum Fehler in der Antwort enthalten sein sollen. Die Antwort behält das zuvor gezeigte Format, es wird nur ausgewählt, welche Einträge enthalten sein sollen.
Der Ansatz, den ich nun vorstelle, erfordert, wie der vorherige Ansatz, dass für jeden technischen Fehler eine eigene Exception-Klasse definiert wird. Ziel ist es, eine Möglichkeit zu schaffen, bestimmte technische Exceptions zu markieren, bei denen die Fehlernachricht in der Antwort enthalten sein soll.
Um das zu konfigurieren, könnt ihr die folgende ErrorAttributes-Bean
im Spring-Kontext registrieren:
@Bean
public ErrorAttributes errorAttributes() {
// Die Standardimplementierung von ErrorAttributes verwenden ...
return new DefaultErrorAttributes() {
// ... aber eine kleine Änderung bei den ErrorAttributes vornehmen
@Override
public Map<String, Object> getErrorAttributes(
WebRequest webRequest, ErrorAttributeOptions options) {
final var error = getError(webRequest);
if (shouldIncludeErrorMessage(error)) {
options = options.including(Include.MESSAGE);
}
return super.getErrorAttributes(webRequest, options);
}
};
}
private static boolean shouldIncludeErrorMessage(Throwable error) {
// todo: decide when to include the exception message
return false;
}
Da wir das Standardverhalten zum Großteil beibehalten möchten, verwenden wir eine Instanz der DefaultErrorAttributes
und überschreiben nur die Methode, die entscheidet, welche Attribute enthalten sein sollen. In dieser Methode fragt man mit getError
den Fehler ab, der während der Verarbeitung aufgetreten ist. Dann prüft man in shouldIncludeErrorMessage,
ob der Fehler eine von uns angegebene Bedingung erfüllt. Wenn das der Fall ist, fügt man den Wert MESSAGE
den ErrorAttributeOptions
hinzu und gibt die angepassten ErrorAttributeOptions
dann an die Standardimplementierung zurück.
Wir nehmen hier also nur einen minimalinvasiven Eingriff vor und lassen alles andere wie es ist. Aber mit diesem Eingriff haben wir einen guten Integrationspunkt in die Fehlerbehandlung, mit dem wir je nach aufgetretenem Fehler beliebig entscheiden können, welche Details in der Antwort enthalten sein sollen.
Nun müssen wir nur noch in shouldIncludeErrorMessage
entscheiden, wie der Fehler behandelt werden soll. Eine Möglichkeit wäre es, in dieser Methode aufzulisten, bei welchen Fehlern sie greifen soll:
private static boolean shouldIncludeErrorMessage(Throwable error) {
return error != null
&& error.getClass() == DuplicateUsernameException.class;
}
Mit dieser kleinen Konfiguration können wir beim Standardformat der Fehlerantwort bleiben, die Details standardmäßig ausblenden, sie aber trotzdem bei ausgewählten Fehlern zurückgeben. Besser wäre es, dieses Verhalten direkt in der Exception-Klasse definieren zu können.
Fachliche Fehler per Annotation markieren
In Java gibt es mehrere Möglichkeiten, Klassen so zu kennzeichnen, dass diese Kennzeichnung später im Programm abgefragt werden kann. Dies kann beispielsweise über eine gemeinsame Oberklasse oder die Implementierung eines Interfaces geschehen. Da die Klassenhierarchie der Exceptions aber auch für andere Zwecke verwendet werden kann, stelle ich hier eine orthogonale Lösung mit Annotationen vor.
Zuerst wird eine neue Annotation definiert, die die Exception-Klassen kennzeichnet, für die der Fehler in der Antwort enthalten sein soll:
// Die Annotation gilt nur an Klassen, nicht an Feldern oder Methoden
@Target(ElementType.TYPE)
// Die Annotation wird zur Laufzeit der Anwendung benötigt
@Retention(RetentionPolicy.RUNTIME)
public @interface IncludeExceptionMessage {}
Dann können wir die zuvor definierte Methode shouldIncludeErrorMessage
so anpassen, dass sie auf alle Exceptions mit dieser Annotation reagiert:
private static boolean shouldIncludeErrorMessage(Throwable error) {
return error != null &&
error.getClass().isAnnotationPresent(IncludeExceptionMessage.class);
}
Und zuletzt markieren wir unseren fachlichen Fehler mit der neuen Annotation:
@IncludeExceptionMessage
@ResponseStatus(value = HttpStatus.BAD_REQUEST,
reason = "DUPLICATE_USERNAME")
public class DuplicateUsernameException extends RuntimeException {}
Jetzt können wir isoliert in einem beliebigen Teil der Anwendung neue Fehler definieren und müssen uns nicht darum kümmern eine zentrale Konfiguration anzupassen.