Neuigkeiten von trion.
Immer gut informiert.

Spring Security SPA und Angular CSRF Schutz

Cross-Site-Request-Forgery oder kurz CSRF ist ein Angriff, bei dem der Browser eines Nutzers auf einer böswilligen Webseite dazu veranlasst wird, einen Request gegen eine andere Webseite durchzuführen. Ist der Nutzer auf der Zielwebseite eingeloggt, sendet der Browser bei manchen Verfahren die notwendigen Informationen automatisch mit, sodass der Request ebenfalls authentifiziert erfolgt. Dadurch kann der Angreifer Aktionen im Namen des Nutzers ausführen.

Ein populärer CSRF Angriff ist z.B. der Facebook "like" Angriff, bei dem Nutzer unbeabsichtigt und unwissentlich "Like" für Inhalte abgaben. Dies führte zur Verbreitung von unerwünschten Inhalten und Manipulation des Rankings.

In diesem Beitrag wird der CSRF Angriff im Detail erläutert und in Kombination von Spring Security und einer Browser Anwendung auf Basis von Angular diskutiert, wie ein entsprechender Schutz implementiert werden kann.

CSRF Angriff

Ein CSRF (Cross-Site Request Forgery) Angriff findet im Kontext von Webanwendungen statt. Bei diesem Angriff nutzt der Angreifer die Tatsache aus, dass viele Webanwendungen Aktionen aufgrund von Anfragen ausführen, die vom Browser des Benutzers gesendet werden, ohne diese Anfragen ausreichend zu überprüfen. Dabei kann ein Browser durch verschiedene Verfahren dazu gebracht werden, Requests gegen Dritt-Webseiten durchzuführen.
Das einfachste Beispiel ist ein <img…​ > Link, der auf ein Bild auf einer anderen Webseite verweist. Damit das Bild dargestellt werden kann, wird ein HTTP GET Request entsprechend der URL im Link durch den Browser ausgeführt.
Alternativ kann dies auch durch JavaScript dynamisch erfolgen.

Der typische Ablauf eines CSRF-Angriffs ist wie folgt:

  1. Der Angreifer erstellt eine präparierte Webseite oder sendet einen schädlichen Link an das Opfer, welches eine vertrauenswürdige Webanwendung enthält.

  2. Das Opfer klickt auf den Link oder besucht die Webseite, während es in der vertrauenswürdigen Webanwendung angemeldet ist.

  3. Die präparierte Webseite führt im Hintergrund automatisch Anfragen an die vertrauenswürdige Webanwendung aus, ohne dass das Opfer dies bemerkt.

  4. Die vertrauenswürdige Webanwendung verarbeitet diese Anfragen, als wären sie vom Opfer selbst ausgeführt worden, da sie von dessen Browser stammen.

Um sich gegen CSRF Angriffe zu schützen, muss eine Webanwendung validieren, ob ein Request tatsächlich durch den Nutzer gewollt ist, oder nicht. Dazu bietet beispielsweise das OWASP Projekt eine Referenz, die unter https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html verfügbar ist.

Schauen wir uns die Umsetzung auf Basis einer Angular-Anwendung im Frontend, stellvertretend für andere Browsertechnologien wie React und Vue, und Spring Boot mit Spring Security im Backend an.

Angular Authentifizierung bei Spring Security

Um sich mit einer Angular-Anwendung, oder auch anderen SPA-Technologie, gegenüber einer Spring-Boot- bzw. Spring-Security-Anwendung zu authentifizieren gibt es unterschiedliche Möglichkeiten. Die Wahl des Verfahrens hängt zunächst davon ab, ob man eine lokale Nutzerverwaltung in der Spring Boot/Security-Anwendung implementieren möchte, oder nicht. Bei letzterer Option wird man typischerweise einen Identity-Service nutzen, der entwerder als Cloud-/SaaS-Angebot bereit steht, oder man betreibt z.B. Keycloak dazu selbst. Gerade wenn höhere Anforderungen an Sicherheit, Datenschutz und Flexibilität bestehen, ist die Wahl einer Architektur mit ausgelagertem Identity-Service eine gute Wahl.
Alternativ kann eine lokale Nutzerverwaltung genutzt werden, dann sind jedoch die typischen Aspekte wie Passwort-Reset, Multi-Faktor-Authentifizierung oder auch Passwordless-Login entsprechend selbst zu implementieren.

Die folgenden grundsätzlichen Verfahren zur Authentifizierung bei Spring Security stehen zur Wahl:

  • Lokale Anmeldung mit Session

  • mTLS / Client Zertifikat

  • Basic Auth (Token)

  • Bearer Token (JWT oder Opaque)

Nur bei Verfahren, bei denen der Browser ohne Beteiligung des Nutzers oder einer clientseitigen Anwendung authentifizierte Requests generiert, besteht ein CSRF-Risiko. Das bedeutet, dass eine Session, wenn Sie durch Cookies umgesetzt ist, oder auch bei einer Authentifizierung durch ein Client-Zertifikat ein CSRF-Risiko besteht.

Muss hingegen für jeden Request ein zusätzlicher Header, wie der Authentication-Header, gesetzt werden, ist bereits eine automatische Absicherung gegen CSRF Angriffe gegeben.

CSRF Schutz

Bei Verwendung von Spring Boot und Spring Security wird automatisch ein CSRF-Schutz aktiviert. Spring Security erwartet dann bei HTTP Requests, die gemäß Semantik zu Veränderungen führen können einen entsprechenden Beweis, dass der Request nicht durch einen Angreifer verursacht wurde.

Das hat zur Konsequenz, dass Token-basierte Implementierungen einer gesonderten CSRF-Konfiguration bedürfen, damit sie nicht durch den Schutz blockiert werden.
Betrachten wir die verschiedenen Varianten anhand einer Beispielanwendung mit Spring Boot 3.1 und Spring Security 6.

Beispiel Anwendung

Als Beispielanwendung wird eine Spring Boot 3.1 Anwendung mit Spring Security 6 verwendet. Auf eine Datenbank oder serverseitiges Rendering wird für dies Beispiel verzichtet.

Die Anwendung kann beispielsweise durch diesen Link generiert werden:

Um Tests zu vereinfachen wird ein fester lokaler Nutzer mit festem Passwort konfiguriert. Dabei handelt es sich nicht um produktionsreifen Code, sondern es dient der einfachen Illustration.

@Bean
public InMemoryUserDetailsManager userDetailsManager() {
    UserDetails user1 =
        User.withUsername("user")
          .password("{noop}password") // Gehashtes Password mit "kein"-Hashing
          .build();
    return new InMemoryUserDetailsManager(user1);
}

Dazu wird ein extrem simpler Endpunkt angeboten, der zum einen lesenden und zum anderen schreibenden Zugriff bereitstellt.

@RestController
@RequestMapping("/basic")
public class SampleController
{
    private String state = "default value from start";

    @PostMapping
    void setState(@RequestBody String state) {
        this.state = state;
    }
    @GetMapping
    String getState() {
        return state;
    }
}

Die Anwendung kann nun gestartet werden und es können Testaufrufe durchgeführt werden. Um noch besser zu sehen, wie Spring Security den Request behadelt, kann das Logging-Level vorher noch erhöht werden, zum Beispiel in der application.properties Datei.

logging.level.org.springframework.security=debug

Ein Aufruf kann nun, z.B. mit httpie oder cURL, unter Verwendung von Basic-Auth durchgeführt werden. Unauthentifiziert ist der Zugriff durch die Standardeinstellungen von Spring Security verboten. Der lesende Zugriff liefert dabei den vorbelegten Wert.

$ http --auth user:password :8080/basic

HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 24
Content-Type: text/plain;charset=UTF-8
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

default value from start

Bei einem schreibenden Zugriff wird jedoch trotz Verwendung von Basic-Auth der Request abgewiesen, bzw. mit einem HTTP-302 als Weiterleitung auf die Form-Login Route beantwortet.

$ echo "new state" | http --auth user:password :8080/basic

HTTP/1.1 302
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 0
Expires: 0
Keep-Alive: timeout=60
Location: http://localhost:8080/login;jsessionid=384B550F5A49756B6AA160401641EA49
Pragma: no-cache
Set-Cookie: JSESSIONID=384B550F5A49756B6AA160401641EA49; Path=/; HttpOnly
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

Der Grund dafür ist auch im Logfile zu sehen:

o.s.security.web.FilterChainProxy        : Securing POST /basic
o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for http://localhost:8080/basic
o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code

Der Statuscode und ob ein Redirect auf ein Login erfolgt hängt dabei von ab, was der Client als unterstützte Content-Types signalisiert.
Bevor wir dies im Detail weiter betrachten, erstellen wir noch Unit-Tests, um das Verhalten zu dokumentieren und zu validieren. Dazu bietet Spring Security Testunterstützung, die mit einer zusätzlichen Abhängigkeit aktiviert werden kann.

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-test</artifactId>
  <scope>test</scope>
</dependency>

Wir zeigen die drei Testfälle auf:

  1. Unauthentifiziert ist kein Zugriff möglich, Status ist 401

  2. Authentifiziert ist lesender Zugriff möglich

  3. Authentifiziert ist kein schreibender Zugriff möglich, Status ist 403

@SpringBootTest
@AutoConfigureMockMvc
class BasicTests
{
    @Autowired
    MockMvc mockMvc;

    @Test
    void unauth() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/basic")
           )
           .andExpect(status().isUnauthorized());
    }

    @Test
    void read() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/basic")
              .with(httpBasic("user", "password"))
           )
           .andExpect(status().isOk());
    }

    @Test
    void write() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/basic")
              .content("new value")
              .with(httpBasic("user", "password"))
           )
           .andExpect(status().isForbidden());
    }
}

Token-basierte Authentifizierung

Das vorherige Beispiel mit HTTP-Basic-Authentifizierung dient zunächst zur Veranschaulichung der automatischen Konfiguration von Spring Security. Für bestimmte Endpunkte, wie beispielsweise den Actuator, kann HTTP-Basic durchaus genutzt werden, wenn eine TLS-Verschlüsselung sicherstellt, dass Username/Passwort nicht abgehört werden können.
Um Nutzer damit zu authentifizieren gibt es jedoch einen entscheidenden Punkt zu beachten:
Da Username und Passwort in jedem Request mitgesendet werden, müssen diese Credentials auch jedes mal erneut validiert werden. Gemäß dem aktuellen Stand der Technik würde man Passwörter niemals im Klartext zu Vergleichszwecken ablegen, sondern stets mit einer Einweghashfunktion sichern. Diese Funktion wird typischerweise so gewählt, dass sie schwer ist, also ein typischer Server eine halbe Sekunde oder sogar mehr Rechenaufwand investieren muss. Dazu kommt ggf. auch noch Speicherbedarf.
Dies dient dazu, dass Vorberechnungen (Rainbow Tables) oder Brute-Force-Angriffe auch mit GPU-Beschleunigung oder spezieller Hardware stark erschwert werden.

Hat man jedoch viele Nutzer auf dem eigenen System und muss bei jedem Request erneut das Passwort zum Vergleich entsprechend aufwendig hashen, schafft man sich hier einen enormen Skalierungsengpass. Daher ist von HTTP-Basic typischerweise abzuraten.
Alternativen sind kryptografisch signierte Tokens, die schnell validiert werden können. Dafür erhöhen sich jedoch die Anforderungen an die Client-Anwendung und den Server, um diese Architektur zu implementieren.

Inzwischen sehr verbreitet sind JWT Tokens: Diese Tokens liegen im JSON-Format vor und lassen sich mit vielen verschiedenen Libraries erzeugen und validieren. Dies ist insbesondere im Kontext einer externalisierten Identity-Lösung wie beispielsweise Keycloak sinnvoll. Damit werden dann auch Standardprotokolle wie OAuth2 und OpenID Connect (OIDC) direkt bereitgestellt.
Spring Security arbeitet mit dem Spring Authorization Server Projekt daran, entsprechende eigene Bausteine bereitzustellen, wenn ohne externe Identity-Lösung gearbeitet werden soll, oder um sogar eigene Identity-Lösungen auf Basis von Spring Boot und Spring Security zu erstellen.
Um die Komplexität in der Anwendungslandschaft gering zu halten sollte man sich die Option einer Session unbedingt als Variante vor Augen halten: Wenn es sich lediglich um lokale User handelt, ist dieser Ansatz deutlich schlanker und funktioniert auch sehr gut mit Angular, Vue, React und sogar serverseitig gerenderten Inhalten.

Bevor wir zur Session als Alternative kommen: Wie kann das CSRF-Thema nun im Kontext von Tokens gelöst werden? Der einfachste Weg: CSRF-Schutz abschalten!
Da bei Tokens sowieso Custom-Header gesetzt werden, ist per-se ein CSRF Angriff nicht möglich, denn CSRF setzt voraus, dass der Request ein GET oder simpler POST Request ohne Header ist. Für JWT und Basic-Auth könnte die Konfiguration daher wie folgt angpasst werden.

@Bean
public SecurityFilterChain basicCsrf(HttpSecurity security) throws Exception {
    return
        security
          .securityMatcher("/basic-nocsrf/**")
          .csrf(AbstractHttpConfigurer::disable)
          .httpBasic(Customizer.withDefaults())
          .authorizeHttpRequests(a -> a.anyRequest().authenticated())
          .build();
}

Die Tests ändern sich nun insofern, als auch der schreibende Zugriff möglich wird.

@Test
void unauthCsrf() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.get("/basic-nocsrf")
        )
        .andExpect(status().isUnauthorized());
}

@Test
void readCsrf() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.get("/basic-nocsrf")
          .with(httpBasic("user", "password"))
        )
        .andExpect(status().isOk());
}

@Test
void writeCrsf() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.post("/basic-nocsrf")
          .content("new value")
          .with(httpBasic("user", "password"))
        )
        .andExpect(status().isOk());
}

Wichtig: Bei HTTP-Basic setzt das Szenario voraus, dass der User sich nicht mit dem nativen Browser-Dialog anmeldet, denn dann sendet der Browser ggf. wieder automatisch den Authentication-Header, je nach CORS-Konfiguration.

Um in einer SPA, Http-Requests abzuschicken, können Hilfsfunktionen bzw. -klassen verwendet werden. Im Fall von Angular eignen sich z.B. Services hierfür. Um API-Requests durchzuführen, verwenden wir im folgenden Beispiel den RequestService, der wiederum den Angular-eigenen HttpClient nutzt, um die Http-Requests zu initiieren. Zu sehen ist die beispielsweise in der "basicReq()"-Methode.

@Injectable({providedIn: 'root'})
export class RequestService {
  constructor(private readonly http: HttpClient) {}

  basicReq(): Observable<string> {
    return this.http.post(
      'http://localhost:8080/basic-nocsrf',
      'demo-data',
      {responseType: 'text'}
    );
  }
}

Um nun ein Token - hier das oben gezeigt Basic-Auth-Token - zu den Requests hinzuzufügen, kann in Angular das Konzept der Interceptoren verwendet werden. Diese können - entsprechend ihres Namens - jeden Request Intercepten und sowohl den als solches Request, oder auch die Response verändern. In unserem Fall wird der Authorization: Basic-Header gesetzt, wobei das Token aus einem AuthService stammt. In diesem Beispiel speichert der Auth-Service lediglich den Nutzernamen und das Passwort und setzt diese dann jeweils zum Basic-Auth-Token zusammen.

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private readonly authService: AuthService) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const token = this.authService.getBasicAuthToken();
    let headers = request.headers;
    if(token) {
      headers = headers.set('Authorization', 'Basic ' + token);
    }
    return next.handle(request.clone({headers}));
  }
}

Session basierte Authentifizierung

Eine HTTP Session wird durch einen Cookie, oder selten auch Path-Rewriting, umgesetzt. Dabei enthält der Cookie typischerweise lediglich eine Session-ID und die tatsächlichen Informationen werden serverseitig oder in einem externalisiertem Speicher wie Redis oder einer Datenbank gehalten.

Sessions lassen sich sowohl mit serverseitig gerenderten Anwendungen als auch mit SPAs, wie z.B. Angular nutzen.

Eine Konfiguration, die die Anmeldung mit HTTP-Basic ermöglicht könnte folgendermaßen aussehen. Dabei ist zu beachten, dass der CSRF Schutz für die login Route deaktiviert ist. Das ist nicht optimal, jedoch soll das Beispiel auch nicht zu komplex werden.

@Bean
public SecurityFilterChain session(HttpSecurity security) throws Exception {
    return
        security
          .securityMatcher("/session/**")
          .httpBasic(Customizer.withDefaults())
          .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
          .authorizeHttpRequests(a -> a
                .anyRequest().authenticated())
          .csrf(c -> c.ignoringRequestMatchers(AntPathRequestMatcher.antMatcher("/session/login")))
          .build();
}

Der zugehörige Controller liefert lediglich den String OK.

@PostMapping("/login")
String login(Authentication authentication, HttpSession session) {
    logger.info("Login: '{}', id {}", authentication.getName(), session.getId());
    return "OK";
}

Beispielaufrufe mit httpie zum Erzeugen der Session und Verwendung mit einem lesenden Request sind hier gezeigt:

$ http --auth user:password POST :8080/session/login

HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 2
Content-Type: text/plain;charset=UTF-8
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Set-Cookie: JSESSIONID=85491ADB046F9E41F45C6C06973DB092; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

OK


$ http :8080/session Cookie:JSESSIONID=85491ADB046F9E41F45C6C06973DB092

HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 24
Content-Type: text/plain;charset=UTF-8
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

default value from start

Die zugehörigen Tests zeigen die Verwendung der Session in Kombination mit MockMvc.

@Test
void read() throws Exception
{
    var session = (MockHttpSession) mockMvc
        .perform(MockMvcRequestBuilders.post("/session/login")
          .with(httpBasic("user", "password"))
        )
        .andReturn().getRequest().getSession();

    mockMvc.perform(MockMvcRequestBuilders.get("/session")
          .session(session))
        .andExpect(status().isOk());
}

Schreibende Requests sind aufgrund des CSRF-Schutzes nicht möglich.

$ echo "foo" | http   :8080/session Cookie:JSESSIONID=85491ADB046F9E41F45C6C06973DB092
HTTP/1.1 403
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Type: application/json
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

{
    "error": "Forbidden",
    "message": "Forbidden",
    "path": "/session",
    "status": 403
}

Das zeigt auch der zugehörige Testfall. Derartige Testfälle sind sehr wichtig, um eine versehentliche Fehlkonfiguration der Anwendung automatisch zu erkennen. Daher sollte auf sie nicht verzichtet werden.

@Test
    void write() throws Exception
    {
        var session = (MockHttpSession) mockMvc
           .perform(MockMvcRequestBuilders.post("/session/login")
              .with(httpBasic("user", "password"))
           )
           .andReturn().getRequest().getSession();

    mockMvc.perform(MockMvcRequestBuilders.post("/basic")
            .content("new value")
            .session(session)
        )
        .andExpect(status().isForbidden());
}

Die schreibenden Zugriffe sind, wie zu erkennen, das Einfallstor für CSRF-Angriffe. Und hier gilt es nun einen Weg zu finden den CSRF-Schutz aktiviert zu lassen und geschickt zu nutzen.

Synchronizer CSRF Pattern

Das Synchronizer Pattern nutzt folgenden Mechanismus: Im HTTP Request wird ein geheimer Wert übermittelt, das sogenannte CSRF Token. Der Wert wird dabei durch den Server mit einem Referenzwert verglichen, der in der HTTP Session hinterlegt ist.

Folgende Empfehlungen gelten für CSRF Tokens bei Verwendung des Synchronizer Patterns:

  • Das Token muss pro User und Session eindeutig sein

  • Der Wert des Token ist geheim zu halten (nicht in Logdateien o.ä. publizieren)

  • Unvorhersagbar sein (durch sichere Methode erzeugter Zufallswert)

Bei dieser Umsetzung des CSRF Schutzes sollte das Token nicht Cookies übermittelt werden. Ein Weg, an den Wert zu gelangen, wäre im Body eines HTTP GET Requests, z.B. mit einem /csrf Endpunkt. Zur Illustration wird im Beispiel das Token bei einem erfolgreichen Login im Body zurückgeliefert.

@PostMapping("/login-and-token")
String loginAndToken(Authentication authentication, HttpSession session, CsrfToken csrfToken)
{
    logger.info("Login: '{}', id {}", authentication.getName(), session.getId());
    return csrfToken.getToken();
}

Das Token muss in einem separaten HTTP Header in jedem Request mitgeliefert werden. Spring Security nutzt automatisch bereits die Header X-CSRF-TOKEN und X-XSRF-TOKEN, es sind jedoch auch eigene Header möglich.

Der zugehörige Test könnte dann wie folgt umgesetzt sein.

@Test
void writeWithCsrf() throws Exception
{
    var result = mockMvc
        .perform(MockMvcRequestBuilders.post("/session/login-and-token")
            .with(httpBasic("user", "password"))
        )
        .andReturn();

    var session = (MockHttpSession) result.getRequest().getSession();
    var csrf = result.getResponse().getContentAsString();

    mockMvc.perform(MockMvcRequestBuilders.post("/basic")
            .content("new value")
            .header("X-CSRF-TOKEN", csrf)
            .session(session)
        )
        .andExpect(status().isOk());
}

Mit httpie sehen die Aufrufe vergleichbar aus.

$ http --auth user:password POST :8080/session/login-and-token
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 96
Content-Type: text/plain;charset=UTF-8
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Set-Cookie: JSESSIONID=C5B941F288BD03E2DA305A8E1F6C985A; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

Z6FAybKsOVbURCyXqm2...

$ echo "foo" | http   :8080/session Cookie:JSESSIONID=C5B941F288BD03E2DA305A8E1F6C985A X-CSRF-TOKEN:Z6FAybKsOVbURCyXqm2...
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 0
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

Um einen Login damit verbunden eine Backend-Session aus der SPA bzw. Angular-Anwendung heraus aufzubauen, können wieder Services genutzt werden. Der AuthService wird im Beispiel unten genutzt, um die Login- und Session-Daten zu verwalten. Die Session wird in der Methode sessionLogin() durch einen Request auf den session/login-and-token-Endpunkt wiederum mit Hilfe von Basic-Auth initialisiert. Das aus diesem Request erhaltene Session-Token wird nun in dem Property authSession gespeichert und kann bei Bedarf abgefragt werden.

@Injectable({providedIn: 'root'})
export class AuthService {

  private authSession?: string;

  constructor(private readonly http: HttpClient) {}

  sessionLogin(username: string, password: string): void {
    const headers = new HttpHeaders()
      .set('Authorization', 'Basic' + btoa(username + ':' + password));
    this.http.post('/api/session/login-and-token', {}, {responseType: 'text', headers})
      .subscribe(val => this.authSession = val);
  }

  getCsrfToken(): string | undefined {
    return this.authSession;
  }
}

Die API-Requests werden dann wieder von einem RequestService ausgeführt. In diesem Fall müssen von Service selbst keine weiteren Parameter zum Request hinzugefügt werden.

@Injectable({providedIn: 'root'})
export class RequestService {
  constructor(private readonly http: HttpClient) {}

  basicSessionReq(): Observable<string> {
    return this.http.post(
      '/api/basic',
      'new value',
      {responseType: 'text'}
    );
  }
}

Damit der CSRF- bzw. XSRF-Schutz aber gewahrt bleibt, muss zu den API-Requests aber das im AuthService gespeicherte authSession-Token hinzugefügt werden. Dazu kann wieder ein (Auth-)Interceptor verwendet werden, der das Token dann für jedem Request in den X-CSRF-TOKEN-Header setzt.

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private readonly authService: AuthService) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const csrf_token  = this.authService.getCsrfToken();
    let headers = request.headers;
    if(csrf_token) {
      headers = headers.set('X-CSRF-TOKEN', csrf_token);
    }
    return next.handle(request.clone({headers}));
  }
}

Dies Verfahren eignet sich nicht so gut, wenn man zustandslose Anwendungen absichern möchte, da durch die Session eine Stateful-Anwendung entsteht. Die Session kann jedoch mit Spring Session externalisiert werden.

Für zustandslose Anwendungen kann alternativ das folgende Muster verwendet werden.

Das Double-Submit-Cookie-Verfahren zum Schutz gegen CSRF-Angriffe ist speziell für Webanwendungen geeignet, die zustandslose Backends mit JavaScript-Frontends vereinigen. Dabei wird ein Cookie verwendet, der durch JavaScript ausgelesen werden kann - entsprechend des Same-Origin-Schutzes des Browsers. Kann nun eine Webanwendung HTTP-Requests erstellen, die den Inhalt des Cookies in einem speziellen HTTP-Header mitsenden, ist damit der Beweis erbracht, dass es sich nicht um einen CSRF Angriff handelt, sondern einen legitimen Request.
Das Verfahren lässt sich dabei analog auch für Session behaftete Anwendungen einsetzen.

Die Umsetzung des CSRF Double-Submit-Cookie Schutzes wird dabei mit den folgenden Schritten empfohlen:

  • Verwendung eines statischen Secrets, z.B. als Umgebungsvariable, als HMAC Key

  • Pro Session/Token wird ein kryptografisch sicherer Zufallswert zusammen mit der Session- oder Token-ID als HMAC-Inhalt verwendet

  • Mit einem kryptografisch sicheren Hashverfahren, z.B. SHA256, wird ein HMAC-Hash aus dem Inhalt und dem HMAC-Key generiert

  • Das CSRF-Token hat dann als Inhalt den HMAC-Hash und den HMAC-Inhalt

  • Der so generierte CSRF-Token wird in einem Cookie, z.B. XSRF-TOKEN, der nicht mit dem httpOnly Flag versehen ist, abgelegt

  • Bei allen Requests sendet der Client den Inhalt des XSRF-TOKEN Cookies in einem HTTP-Header, z.B. X-XSRF-TOKEN an den Server

  • Der Server vergleicht, ob die Inhalte von Cookie und Header identisch sind und der HMAC korrekt ist

Bei der Konfiguration in Spring Security ist entsprechend als CSRF Token-Repository das CookieCsrfTokenRepository als Strategie zu wählen. Da es sich in unserem Beispiel um eine zustandslose Anwendung handelt, gibt es keinen expliziten Login oder eine Session. Daher wird lediglich ein /csrf Endpunkt bereitgestellt, über den der Tokeninhalt als Cookie abgerufen werden kann.
Hier ist zu beachten, dass Spring Security zum Schutz gegen BREACH-Angriffe das CSRF-Token um Zufallsdaten erweitert. Der BREACH-Angriff basiert darauf, dass sich die Daten im Body einer Antwort nicht ändern, womit es dem Angreifer unter bestimmten Umständen möglich wird den Inhalt zu ermitteln. Damit passen in unserem Beispiel der Cookie-Wert, nicht gegen BREACH geschützt, da es sich um einen Header handelt, und der erwartete Inhalt des X-XSRF-TOKEN Headers im gegen BREACH geschützten Format nicht mehr zusammen.
Dies lässt sich jedoch durch Verwendung des CsrfTokenRequestAttributeHandler statt des standardmäßig konfigurierten XorCsrfTokenRequestAttributeHandler wieder passend einstellen. Der eigentliche Daten-Endpunkt ist in diesem Beispiel /stateless. Da wir keine Session haben, müssen die Login-Daten bei jedem Request mit gesendet werden. In unserem Beispiel sind dies wieder die Basic-Auth-Daten. Typischerweise würde hier z.B. ein Client-Zertifikat zum Einsatz kommen. Eine beispielhafte Konfiguration könnte wie folgt aussehen.

@Bean
public SecurityFilterChain stateless(HttpSecurity security) throws Exception
{
    return
        security
            .securityMatcher("/stateless/**")
            .httpBasic(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.NEVER))
            .authorizeHttpRequests(a -> a
                .anyRequest().authenticated())
            .csrf(c -> c
                .ignoringRequestMatchers(AntPathRequestMatcher.antMatcher("/stateless/csrf"))
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                )
            .build();
}

Der zugehörige Test könnte dann wie folgt umgesetzt sein.

@Test
void writeWithCsrf() throws Exception
{
    var csrf = mockMvc
        .perform(MockMvcRequestBuilders.get("/stateless/csrf")
            .with(httpBasic("user", "password"))
        )
        .andReturn().getResponse().getCookie("XSRF-TOKEN").getValue();

    mockMvc.perform(MockMvcRequestBuilders.post("/stateless")
            .with(httpBasic("user", "password"))
            .content("new value")
            .header("X-XSRF-TOKEN", csrf)
            .cookie(new Cookie("XSRF-TOKEN", csrf))
        )
        .andExpect(status().isOk());
}

Um das Double-Submit-Cookie in Angular zu nutzen, muss fast nichts getan werden, da Angular den größten Teil selbst erledigt: Zunächst benötigen wir natürlich einen Request, mit dem wir uns das Double-Submit-Cookie besorgen können. Dies durch Aufruf der doubleSubmitLogin()-Methode des AuthService. Hier findet die Anmeldung am Backend per Basic-Auth-Request statt, in der Praxis könnten hier z.B. Client-Zertifikate oder ähnliches verwendet werden. Was bei dieser Variante auffällt: Das CSRF-Cookie muss nicht manuell aus dem Request ausgelesen werden. Das liegt daran, dass Angular und Spring Boot beide das standardisierte XSRF-TOKEN-Cookie "verstehen" können. Mit anderen Worten: Wenn Spring Boot ein Cookie XSRF-TOKEN mit schickt, liest Angular dieses automatisch aus und speichert es sich intern als Double-Submit-Cookie.

@Injectable({providedIn: 'root'})
export class AuthService {

  constructor(private readonly http: HttpClient) {}

  doubleSubmitLogin(username: string, password: string): void {
    const headers = new HttpHeaders()
      .set('Authorization', 'Basic' + btoa(this.authData.username + ':' + this.authData.password));
    return this.http.get('/api/stateless/csrf', {responseType: 'text', headers})
      .subscribe();
  }
}

Bei nachfolgenden Requests muss dann nichts weiter getan werden, denn Angular schickt dieses Cookie von nun an immer mit. Im RequestService können Requests nun also ohne weiteres abgeschickt werden, denn Angular fügt sowohl Cookie als auch Token automatisch an alle Requests an.

@Injectable({providedIn: 'root'})
export class RequestService {
  constructor(private readonly http: HttpClient) {}

  doubleSubmitReq(): Observable<string> {
    return this.http.post(
      '/api/stateless',
      'new value',
      {responseType: 'text'}
    );
  }
}

Falls das Cookie nicht im Standardformat (XSRF-TOKEN) vorliegt, oder der standard Header-Name (X-XSRF-TOKEN) in der Response angepasst werden muss, kann dies ebenfalls Konfiguriert werden. Dafür muss lediglich beim Import des Angular-HttpClientModule zusätzlich das HttpClientXsrfModule.withOptions() importiert werden, dem dann die neuen Werte übergeben werden können.

imports: [
  HttpClientModule,
  HttpClientXsrfModule.withOptions({
    cookieName: 'My-Xsrf-Cookie',
    headerName: 'My-Xsrf-Header',
  }),
],

Custom HTTP Header

Eine weitere Möglichkeit ist die Verwendung eines Custom HTTP-Headers. Da dieser nicht ohne aktive Mithilfe der angegriffenen Webseite durch einen Angreifer gesetzt werden kann, ist damit eine recht pragmatische Absicherung gegen CSRF Angriffe möglich.
Im Prinzip wird dasselbe Verfahren, wie bei tokenbasierter Authentifizierung verwendet, ohne jedoch Authentifizierungsdaten im Header zu erfordern.

Der Header könnte dann z.B. so aussehen: X-CSRF-PROTECTION=1.

Spring Security unterstützt dies Verfahren nicht unmittelbar.

CORS und CSRF

Die vorgestellten Verfahren zum Schutz gegen CSRF Angriffe lassen sich ebenfalls zusammen mit CORS, also Cross-Origin-Resource-Sharing, kombinieren.
Dabei gilt es insbesondere zu beachten, dass Cookies nicht von fremden Origins ausgelesen werden dürfen. Entsprechend kommen hier andere Optionen infrage:

  • Nutzung von Tokens zur Authentifizierung und als Custom-Header zum Schutz vor CSRF

  • Synchronizer Pattern, dabei muss das Backend den CSRF State ohne Cookie speichern

  • Auswertung des Origin Headers, um zu prüfen, ob der Request von einer vertrauenswürdigen Domain stammt

Entsprechend muss als CORS Option der X-XSRF-TOKEN Header ggf. erlaubt werden. Das sähe auf HTTP Ebene beispielsweise so aus:
Access-Control-Allow-Headers: X-XSRF-TOKEN
wobei in der Regel noch weitere Header für den Rest der Anwendung erlaubt werden müssen.

Fazit

Ein Schutz gegen CSRF Angriffe ist mit wenig Konfigurationsaufwand möglich. Der größte Aufwand entsteht dadurch, sich mit der Thematik zu befassen und die Materie zu durchdringen. Sowohl für Spring Security als auch Angular (React/Vue/…​) gibt es passende Muster zur effektiven Absicherung von Webanwendungen gegen CSRF Angriffe.




Zu den Themen Spring Boot, Spring Security und OWASP sowie Frontendtechnologien bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:

Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.

Feedback oder Fragen zu einem Artikel - per Twitter @triondevelop oder E-Mail freuen wir uns auf eine Kontaktaufnahme!

Zur Desktop Version des Artikels