Validierung per Annotation in Java und Spring
Security ist mittlerweile ein allgegenwärtiges Thema. SQL-Injection oder Cross-Site Scripting Angriffe sind klassische Bedrohungen, die mittels Validierung aller Eingabedaten abgewehrt werden können. Macht man das ungeschickt, ist es aufwändig und unsicher. In diesem Artikel zeige ich euch, wie man auf einfache Weise per Annotation den eigenen Spring Web-Service absichert.
Der harte Weg: Validierung im Java Code
Die beste Methode SQL- oder Code-Injection Angriffe zu verhindern, ist Eingaben dieser Art nicht zu erlauben. Auf die Details wie diese Art von Angriffen funktionieren, möchte ich hier nicht eingehen - ihr könnt es aber z.B. hier nachlesen:
Wichtig ist, dass die Überprüfung der Eingabedaten Server-seitig stattfinden, da Hacker Client-seitige Eingabevalidierung (z.B. mit JavaScript im Browser) umgehen können. Mit Java könnte man die Überprüfung der Eingabedaten mittels regulärer Ausdrücke durchführen und auf diese Weise ungültige oder schädliche Eingaben (wie SQL-Injection) verhindern. Hier ist das exemplarisch für eine POST Methode eines Spring Rest-Controllers gezeigt:
@PostMapping("/old-school")
public ResponseEntity<Long> createUser(@RequestBody User user) {
String notValidatedName = user.getName();
String regexForName = "[A-Z a-z]{1,20}";
if (notValidatedName == null
|| !notValidatedName.matches(regexForName))
throw new IllegalArgumentException();
// Validation of other attributes is skipped here...
// Business logic is also skipped...
}
Die Klasse String bietet in Java die Methode matches an, um einen String anhand eines regulären Ausdrucks zu überprüfen. Wenn der reguläre Ausdruck als Parameter an die Methode matches übergeben wird, liefert die Methode true, wenn das zu überprüfende String Objekt dem Format des regulären Ausdrucks entspricht - ansonsten false.
Im gezeigten Code Ausschnitt sieht der reguläre Ausdruck für einen Namen eines Objektes der Klasse User so aus: "[A-Z a-z]{1,20}"
- A-Z: bedeutet die Großbuchstaben von A bis Z sind erlaubt.
- a-z: bedeutet die Kleinbuchstaben von a bis z sind erlaubt.
- [A-Z a-z]: bedeutet sowohl die Groß- als auch die Kleinbuchstaben von A bzw. a bis Z bzw. z und das Leerzeichen sind erlaubt.
- {1,20}: bedeutet das der zu überprüfende String mindestens 1 Zeichen haben muss und höchstens 20 Zeichen haben darf.
Für den Fall, dass das String Attribute name der Klasse User nicht dem regulären Ausdruck regexForName entspricht, wirft mein Code eine IllegalArgumentException, so dass die Business Logik nicht erreicht wird und keine invaliden oder schädlichen Daten gespeichert werden.
Das einzelne Validieren jedes Eingabe-Parameters ist ein umständlicher und harter Weg, weil:
- man auf jedes Attribute einzeln zugreifen muss, indem man die jeweilige Getter-Methode verwendet (siehe user.getName()).
- die Validierung jedes Attributes mit selbst geschriebenem Code starten muss (siehe .matches(regexForName)).
- die Fehlerbehandlung selbst machen oder zumindest starten muss (siehe throw new IllegalArgumentException()).
In den folgenden Abschnitten zeige ich wie die Validierung mit Annotationen und Spring einfacher und eleganter implementiert werden kann. 😮
Eingabedaten-Validierung mit Spring und Hibernate |
Der elegante Weg: Validierung per Annotation
Wäre es nicht elegant, wenn die Validierungs-Regeln für Eingabedaten dort definiert und angewendet werden, wo auch die Eingabe-Parameter deklariert werden? In Spring MVC geschieht die Deklaration in den Modell Klassen. Alle Werte die z.B. per POST Request unsere Applikationslogik erreichen können, sind in der zugehörigen Methode als Parameter deklariert, siehe @RequestBody User user im Beispiel oben.
Hibernate Bean Validation ist die elegante Lösung für unser Validierungsaufgaben, siehe dazu auch:
Da wir Spring Boot zum Aufsetzen unserer Projekte verwenden, können wir die Hibernate Bean Validation einfach per Spring Boot Starter Dependency in unser Projekt laden. Dazu folgende Dependency in der Maven pom.xml Datei definieren:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Annotationen
Danach sind wir bereit mittels Annotation jedes Attribut in unseren Modell-Klassen zu validieren. Am Beispiel der Modell-Klasse User will ich im folgenden einige Annotationen zeigen und erklären:
import javax.validation.constraints.*;
public class User {
@NotNull
private String name;
@Size(max = 20)
private String street;
@NotBlank
private String city;
@Pattern(regexp = "[A-Z]{1,3}")
private String countryCode;
@Max(99999)
@Min(0)
private Integer zipcode;
// Getter und Setter Methoden...
}
- @NotNull stellt sicher, dass das entsprechend annotierte Attribut (hier name) nicht null sein darf.
- @NotBlank entspricht @NotNull mit der zusätzlichen Bedingung, dass der String nicht leer, also "" oder " " sein darf.
- @Size definiert die valide Länge eines Strings. Hier wird mit max definiert, dass der String street nicht länger als 20 Zeichen sein darf. Alternativ kann man auch einen Minimalen Wert mit min definieren oder einen absoluten Wert mit value. @Size lässt sich auch bei Maps, Collections oder Arrays anwenden. Das gilt übrigens auch für viele andere Validierungs-Annotationen wie z.B. @NotEmpty zum Festlegen, dass eine Liste nicht leer sein darf.
- @Max stellt sicher, dass der Wert des Integer Attributes kleiner gleich dem in @Max definierten Wert (hier 99999) ist.
- @Min stellt sicher, dass der Wert des Integer Attributes größer gleich dem in @Min definierten Wert (hier 0) ist.
@Max und @Min lassen sich auch kombinieren, so wie hier gezeigt.
Wichtig ist, dass beide Annotationen nur auf Zahlen Typen anwendbar sind, wie z.B. Integer, BigDecimal, long oder short. Auf Gleitkommazahlen entsprechend der Typen double oder float können @Min und @Max wegen möglicher Rundungsfehler nicht angewendet werden. - @Pattern wird verwendet um Strings gegen reguläre Ausdrücke zu validieren. Dazu wird der reguläre Ausdruck einfach im Parameter regexp definiert. Wie reguläre Ausdrücke funktionieren, habe ich weiter oben im ersten Abschnitt erklärt - das gilt hier auch wieder.
Validieren direkt mit Hibernate
Wie schon im ersten Abschnitt erwähnt, arbeitet Spring mit dem Hibernate Validator. Diesen können wir nun auch direkt im Code verwenden - also unabhängig von Spring Controllern und REST Serviceimplementierungen. Eine Instanz der Klasse User können wir z.B. so direkt mit dem Hibernate Validator validieren:import javax.validation.*;
...
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
User user = new User();
// Attribute von user mit Setter Methoden definieren
Set<ConstraintViolation<User>> violations = validator.validate(user);
Eine allgemeine Validator Instanz, die zur Validierung beliebiger Modell Klassen verwendet werden kann, erzeugen wir mittels ValidatorFactory. Das User Objekt wird beliebig mit Setter Methoden definiert. Die validate Methode der Validator Instanz führt dann die Validierung aus und gibt ein Set von ConstraintViolation Objekten zurück. Wenn das Set leer ist, ist das User Objekt valide. Ansonsten befindet sich für jede fehlgeschlagene Validierung (anhand einer Annotation) eine ConstraintViolation Instanz im Set - das User Objekt war dann nicht valide.
Da dieser Code relativ einfach ist und bei allen Modell Klassen gleich aussehen würde, kann Spring uns hier Arbeit abnehmen - das zeige ich im nächsten Abschnitt.
Validierung im Controller aktivieren
Im Abschnitt über den harten Weg der Validierung haben wir die POST /old-school createUser Methode gesehen. Eine elegante createUser Methode, die automatisch den Hibernate Validator verwendet, sieht so aus:
import javax.validation.Valid;
@PostMapping
public ResponseEntity<Long> createUser(
@Valid @RequestBody User user) {
// user ist valide, wenn diese Codezeile erreicht wird.
// Business Logik kann direkt anfangen
@Valid ist der einzige Unterschied den die elegante createUser Methoden-Signatur im Vergleich zu /old-school createUser Methoden-Signatur hat. @Valid sorgt dafür das Spring automatisch den Hibernate Validator nutzt, um zu überprüfen, dass der annotierte Parameter valide ist.
Die unchecked Exception MethodArgumentNotValidException wird geworfen, wenn ein mit @Valid annotierter Parameter nicht valide ist. Wenn also ein beliebiger mit @Valid annotierter Parameter beim Methoden Aufruf invalide ist, erreichen wir nie die erste Zeile Code innerhalb unserer Methode. Dadurch sind wir vor Angriffen wie SQL-Injection oder Code-Injection geschützt, wenn unsere Validierungsregeln entsprechend scharf in den Validierungs-Annotationen spezifiziert sind.
@Pattern(regexp = "[A-Z]{1,3}") schützt uns zum Beispiel (übertrieben) effektiv vor SQL- oder JavaScript-Injection, weil keinerlei Sonderzeichen erlaubt bzw. valide sind.
Wenn also die MethodArgumentNotValidException von Spring aufgrund einer fehlgeschlagenen Validierung geworfen wird, so hat Spring auch eine Standard-Fehlerbehandlung, die dem Client den korrekten HPPT-Code 400 Bad Request und eine Fehlermeldung im Standard-Format schickt:
{
"timestamp": "2020-11-14T09:08:49.560+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/user"
}
Leider sieht der Client in der Standard-Fehlermeldung nicht, welche Attribute seines Request genau invalide waren. Wie man dieses letzte Problem löst, zeige ich im nächsten Abschnitt.
Die letzte Meile: Angepasste Fehlermeldungen für den Client
Das Spring DispatcherServlet (für Details siehe https://docs.spring.io/.../DispatcherServlet.html) hat in den Standard-Einstellungen den Spring DefaultHandlerExceptionResolver aktiviert. Der DefaultHandlerExceptionResolver ist Teil des Spring Framework und wandelt alle nicht gefangenen Exceptions in Standard-Fehlermeldungen um. Für einige Exceptions ist dort ihr Mapping auf einen HTTP-Fehlercode definiert, z.B. dass MethodArgumentNotValidException als "Bad Request" mit HTTP-Code 400 aufgelöst wird. Im DefaultHandlerExceptionResolver unbekannte Exceptions werden als "Internal Server Error" mit HTTP-Code 500 aufgelöst.
Wenn wir in unserem Controller eigene Exception-Spezialisierungen oder Exceptions werfen, die im DefaultHandlerExceptionResolver nur als "Internal Server Error" verarbeitet werden oder wenn wir ein anderes Format unserer Fehlermeldungen möchten, dann brauchen wir eigene Exception-Handler. Einen Exception-Handler können wir z.B. so erstellen und zusätzlich zum DefaultHandlerExceptionResolver aktivieren:
import static org.springframework.http.HttpStatus.*;
@ControllerAdvice
public class RestResponseEntityExceptionHandler {
private static final Map<...> STATUS_MAP = Map.of(
MethodArgumentNotValidException.class, BAD_REQUEST,
IllegalArgumentException.class, BAD_REQUEST,
NoSuchElementException.class, NOT_FOUND);
@ExceptionHandler
public ResponseEntity<...> handleException(Exception ex)
throws Exception {
HttpStatus httpCode = STATUS_MAP.get(ex.getClass());
if (httpCode == null)
throw ex;
Map<String, String> error = new TreeMap<>();
error.put("error-type", ex.getLocalizedMessage());
error.put("error-message", httpCode.getReasonPhrase());
return ResponseEntity.status(httpCode).body(error);
}
}
- Hinweis: Um die Lesbarkeit zu erhöhen habe ich die Generics <> durch "..." ersetzt.
- @ExceptionHandler markiert eine Methode als Exception-Handler und wandelt eine im eigenen Code nicht gefangene Exception in eine ResponseEntity um.
Auf diese Weise kann man ein eigenes Format für Fehlermeldungen definieren, ich verwende eine Map<String, String> um im Client dieses JSON anzuzeigen:{"error-message": "Not Found","error-type": "No value present"} - Man kann beliebig viele eigene Exception-Handler definieren, z.B. für jede Exception Spezialisierung (z.B. NoSuchElementException oder IllegalArgumentException) einen eigenen Handler. Ich habe mich für einen Handler entschieden, der die Superklasse Exception und damit alle Arten von Exceptions verarbeitet.
Über die STATUS_MAP ermittle ich den HttpStatus bzw. den HTTP Code zu jeder in meiner Map definierten Exception-Klasse, um die Exception in eine dem Status-Code entsprechende ResponseEntity zu überführen. Falls die Exception-Klasse in meiner Map nicht vorhanden ist, wirft der Handler die Exception, so dass sie vom Spring DefaultHandlerExceptionResolver verarbeitet wird.
Diese Lösung ist nur ein Beispiel, es gibt hier noch diverse andere Möglichkeiten. - Damit unser Exception-Handler in allen Controllern verwendet wird, ist die Annotation @ControllerAdvice notwendig. @ControllerAdivce macht aus der entsprechend annotierten Klasse eine Bean, deren mit @ExceptionHandler annotierten Methoden zur Exception-Verarbeitung in allen Controllern im Spring IoC Container angewendet werden.
Fazit
In diesem Blog Artikel habe ich gezeigt wie man seine Applikation durch Eingabedaten-Validierung vor Code- und SQL-Injection schützen kann. Wir haben gesehen wie Spring uns dabei durch Annotationen unterstützt.
Außerdem haben wir gesehen, wie man nicht gefangene Exception an zentraler Stelle verarbeiten kann, um einheitliche Fehlermeldungen zu produzieren.
Den kompletten Sourcecode mit JUnit Tests findet ihr in GitHub:
Hinterlasst mir bitte Kommentare mit Fragen, Feedback oder sonstigen Anmerkungen. Ich bearbeite diese Themen auch gerne in künftigen Blog-Artikeln.
Kommentare
https://xss-game.appspot.com/level1