Ochrana proti CSRF útokům

V tomto článku se dozvíte, co to jsou CSRF útoky a jak se proti nim bránit. Jedná se o věc, o které podle mě spousta začínajících programátorů webových aplikací ani neví, ale pro bezpečnost webových aplikací je ochrana proti CSRF útokům velmi důležitá.

Co jsou CSRF útoky

Ke Cross-Site Request Forgery (CSRF) útoku dochází, když škodlivá webová stránka přiměje webový prohlížeč provést nějakou operaci na jiném webu, na kterém je uživatel, který webový prohlížeč používá, přihlášen. Webová stránka totiž má možnost posílat requesty i mimo vlastní doménu. Tímto způsobem prohlížeče fungují, není to nic neobvyklého. Příkladem mohou být odkazy, které se na webové stránce mohou nacházet a odkazovat na jakoukoliv jinou webovou stránku na internetu. Stejným způsobem to ale funguje také třeba s formuláři. Takže se může stát, že útočník na svoji webovou stránku umístí formulář směřující na jiný web, který uživatel po otevření stránky odešle. Může to být dokonce provedeno automaticky přes JavaScript.

Implementovat ochranu proti CSRF útokům je důležité, protože samozřejmě nechceme, aby si mohla jakákoliv webová stránka jen tak posílat requesty na náš web, na kterém jsou naši uživatelé přihlášení a provádět za ně různé operace. Implementace ochrany proti CSRF útokům není tak složitá, jak by se na první pohled mohlo zdát. Základní princip spočívá v použití tzv. CSRF tokenů. Jedná se o unikátní hodnotu generovanou serverem, která je přidána do každého formuláře nebo požadavku, který na serveru provádí nějakou operaci, která mění data (formuláře, které slouží jen pro nějaké vyhledávání dat, se samozřejmě zabezpečovat nemusí). Tento token je poté zkontrolován při každém požadavku na server, aby se zajistilo, že požadavek pochází od oprávněného uživatele. Až se pustíme do implementace CSRF ochrany ve vzorové aplikaci, tak pravděpodobně pochopíte jak to funguje. Více si o tvorbě CSRF ochrany můžete přečíst třeba zde.

Ukázka provedení CSRF útoku

Abychom si ukázali, jak CSRF útok může vypadat, vytvoříme si testovací aplikaci, kterou poté napadneme škodlivou webovou stránkou. Bude se jednat o aplikaci, ve které se uživatel bude moci zaregistrovat/přihlásit. Také bude mít možnost svůj účet po přihlášení zrušit. Právě to potom uděláme za uživatele, když navštíví naši škodlivou webovou stránku.

Vytvoření testovací aplikace

Začneme tím, že si založíme nový Maven projekt, který můžeme pojmenovat třeba jako "example-app". Jako závislost si do souboru pom.xml nadefinujeme servlety.

  • src
    • main
      • java
      • resources
      • webapp
    • test
      • java
      • resources
  • target
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>example-app</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>war</packaging>
    <name>example-app</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>17</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>

Teď můžeme vytvořit složku WEB-INF a v ní soubor web.xml, ve kterém si nadefinujeme a namapujeme servlety, které později vytvoříme. Obsah souboru ukazuje následující ukázka.

  • src/main/webapp/WEB-INF
  • src/main/webapp/WEB-INF
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:web="http://Java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" 
id="WebApp_ID" version="3.0">
    <servlet>
        <servlet-name>HomeServlet</servlet-name>
        <servlet-class>com.example.servlets.HomeServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>RegisterServlet</servlet-name>
        <servlet-class>com.example.servlets.RegisterServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>LoginServlet</servlet-name>
        <servlet-class>com.example.servlets.LoginServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>LogoutServlet</servlet-name>
        <servlet-class>com.example.servlets.LogoutServlet</servlet-class>
    </servlet>
    <servlet>
        <servlet-name>DeleteAccountServlet</servlet-name>
        <servlet-class>com.example.servlets.DeleteAccountServlet</servlet-class>
    </servlet>
    
    <servlet-mapping>
        <servlet-name>HomeServlet</servlet-name>
        <url-pattern>/home</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>RegisterServlet</servlet-name>
        <url-pattern>/register</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>LoginServlet</servlet-name>
        <url-pattern>/login</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>LogoutServlet</servlet-name>
        <url-pattern>/logout</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>DeleteAccountServlet</servlet-name>
        <url-pattern>/delete-account</url-pattern>
    </servlet-mapping>
</web-app>

Jak můžete v souboru web.xml vidět, naše aplikace se bude skládat z pěti servletů. Začneme se servletem pro domovskou stránku. Jeho kód a JSP stránky, které vykresluje, ukazují následující ukázky. Servlety můžete vytvářet v balíčku "com.example.servlets" a JSP soubory ve složce WEB-INF/jsp (kdyžtak si to můžete prohlédnou po otevření levého panelu u každé ukázky). Podle toho, zda je uživatel přihlášen (zda je objekt, který uživatele reprezentuje, uložen v session) se zobrazí buď uživatelská domovoská stránka, nebo veřejná domovská stránka.

package com.example.servlets;

import java.io.IOException;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.example.model.User;

public class HomeServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        if (loggedUser == null) {
            // zobrazení domovské stránky pro nepřihlášeného uživatele
            RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/HomePage.jsp");
            dispatcher.forward(req, res);
        } else {
            req.setAttribute("username", loggedUser.getUsername());
            // zobrazení domovské stránky pro přihlášeného uživatele
            RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/UserHomePage.jsp");
            dispatcher.forward(req, res);
        }
    }
}
  • src/main/webapp/WEB-INF/jsp
  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Vítejte</h1>
    <p>Přihlašte se nebo si vytvořte účet.</p>
    <ul>
        <li><a href="./login">Přihlásit se</a></li>
        <li><a href="./register">Zaregistrovat se</a></li>
    </ul>
</body>
</html>
  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Uživatel: ${username}</h1>
    <ul>
        <li><a href="./logout">Odhlásit se</a></li>
        <li><a href="./delete-account">Smazat účet</a></li>
    </ul>
</body>
</html>

Uživatel bude reprezentován třídou User. Její instance budou moci být ukládány v databázi, kterou bude představovat třída UsersDatabase. Obě třídy ukazují následující ukázky. Můžete je vytvořit v balíčku "com.example.model". Nemusíte vůbec zkoumat jak třída UsersDatabase funguje. Je to jen taková testovací databáze, která ukládá data v paměti, abychom pro naše účely nemuseli používat opravdovou databázi.

package com.example.model;

public class User {
    private int id;
    private String username;
    private String password;
    
    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    
    public String getUsername() {
        return username;
    }
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
}
package com.example.model;

import java.util.LinkedList;
import java.util.List;

import javax.servlet.http.HttpServletRequest;

public class UsersDatabase {
    private UsersDatabase() {}
    
    private static int counter = 0;
    
    public static void addUser(HttpServletRequest request, User user) {
        List<User> users = (List<User>) request.getServletContext().getAttribute("USERS");
        if (users == null) {
            users = new LinkedList<User>();
            request.getServletContext().setAttribute("USERS", users);
        }
        
        user.setId(counter);
        counter++;
        
        users.add(user);
    }
    
    public static List<User> getUsers(HttpServletRequest request) {
        List<User> users = (List<User>) request.getServletContext().getAttribute("USERS");
        if (users == null) users = new LinkedList<User>();
        return users;
    }
        
    public static User getUserById(HttpServletRequest request, int id) {
        List<User> users = (List<User>) request.getServletContext().getAttribute("USERS");
        if (users == null) users = new LinkedList<User>();
        
        for (User user : users) {
            if (user.getId() == id) return user;
        }
        
        return null;
    }
    
    public static User getUserByUsername(HttpServletRequest request, String username) {
        List<User> users = (List<User>) request.getServletContext().getAttribute("USERS");
        if (users == null) users = new LinkedList<User>();
        
        for (User user : users) {
            if (user.getUsername().equals(username)) return user;
        }
        
        return null;
    }
    
    public static void deleteUserById(HttpServletRequest request, int id) {
        List<User> users = (List<User>) request.getServletContext().getAttribute("USERS");
        if (users == null) users = new LinkedList<User>();
        
        for (int i = 0; i < users.size(); i++) {
            User user = users.get(i);
            
            if (user.getId() == id) {
                users.remove(i);
                break;
            }
        }
    }
}

Teď můžeme vytvořit servlet pro registraci uživatele. Jeho kód a JSP stránku, kterou vykresluje, ukazují následující ukázky. Servlet zpracovává GET i POST requesty. V metodě doGet zobrazuje stránku s formulářem a v metodě doPost tento formulář zpracovává.

package com.example.servlets;

import java.io.IOException;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.example.model.User;
import com.example.model.UsersDatabase;

public class RegisterServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        // pokud je uživatel již přihlášen, tak je přesměrován na hlavní stránku
        if (loggedUser != null) {
            res.sendRedirect("./home");
            return;
        }
        
        RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/RegisterPage.jsp");
        dispatcher.forward(req, res);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        String username = req.getParameter("username");
        String password = req.getParameter("password");
        
        // pro jednoduchost se nezabýváme žádnou velkou validací, pouze zkontrolujeme
        // zda se uživatel s tímto uživatelským jménem nenachází v databázi
        User existingUser = UsersDatabase.getUserByUsername(req, username);
        
        // pokud uživatel již existuje, tak mu znovu zobrazíme stránku pro registraci
        // (pro jednoduchost nezobrazujeme žádnou validační zprávu)
        if (existingUser != null) {
            res.sendRedirect("./register");
            return;
        }
        
        // vytvoření uživatele
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);
        
        // uložení uživatele do databáze
        UsersDatabase.addUser(req, user);
        
        // uložení uživatele do session
        HttpSession session = req.getSession();
        session.setAttribute("LOGGED_USER", user);
        
        // přesměrování na hlavní stránku
        res.sendRedirect("./home");
    }
}
  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Registrace</h1>
    <form action="./register" method="POST">
        <label>Uživatelské jméno:</label> <input type="text" name="username"/><br>
        <label>Heslo:</label> <input type="password" name="password"/><br>
        <button type="submit">Zaregistrovat se</button>
    </form>
</body>
</html>

Dále vytvoříme servlet pro přihlášení. Jeho kód a JSP stránku, kterou vykresluje, si opět můžete prohlédnout v následujích ukázkách.

package com.example.servlets;

import java.io.IOException;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.example.model.User;
import com.example.model.UsersDatabase;

public class LoginServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        // pokud je uživatel již přihlášen, tak je přesměrován na hlavní stránku
        if (loggedUser != null) {
            res.sendRedirect("./home");
            return;
        }
        
        RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/LoginPage.jsp");
        dispatcher.forward(req, res);
    }
    
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        String username = req.getParameter("username");
        String password = req.getParameter("password");
        
        // nalezení uživatele v databázi podle uživatelského jména
        User user = UsersDatabase.getUserByUsername(req, username);
        
        // pokud se uživatel nenašel nebo je špatné heslo,
        // tak se stránka pro přihlášení zobrazí znovu
        if (user == null || !user.getPassword().equals(password)) {
            res.sendRedirect("./login");
            return;
        }
        
        // uložení uživatele do session
        HttpSession session = req.getSession();
        session.setAttribute("LOGGED_USER", user);
        
        // přesměrování na hlavní stránku
        res.sendRedirect("./home");
    }
}
  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Přihlášení</h1>
    <form action="./login" method="POST">
        <label>Uživatelské jméno:</label> <input type="text" name="username"/><br>
        <label>Heslo:</label> <input type="password" name="password"/><br>
        <button type="submit">Přihlásit se</button>
    </form>
</body>
</html>

Teď vytvoříme servlet pro smazání účtu. Pokud si bude chtít uživatel smazat účet, tak se mu nejprve zobrazí stránka, kde na to bude mít tlačítko, a když na toto tlačítko klikne, tak se jeho účet smaže. Následující ukázky ukazují kód servletu a JSP stránky.

package com.example.servlets;

import java.io.IOException;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.example.model.User;
import com.example.model.UsersDatabase;

public class DeleteAccountServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        if (loggedUser == null) {
            res.sendRedirect("./home");
            return;
        }
        
        RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/DeleteAccountPage.jsp");
        dispatcher.forward(req, res);
    }
    
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        // pokud uživatel není přihlášen, tak je jen přesměrován na hlavní stránku
        if (loggedUser == null) {
            res.sendRedirect("./home");
            return;
        }
        
        // odstranění uživatele z databáze
        UsersDatabase.deleteUserById(req, loggedUser.getId());
        // odstranění uživatele ze session
        session.removeAttribute("LOGGED_USER");
        // přesměrování na hlavní stránku
        res.sendRedirect("./home");
    }
}
  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Smazat účet</h1>
    <p>Opravdu si přejete smazat svůj účet?</p>
    <form action="./delete-account" method="POST">
        <button type="submit">Smazat účet</button>
    </form>
</body>
</html>

Teď již zbývá jen servlet pro odhlášení. Ukazuje jej následující ukázka.

package com.example.servlets;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

public class LogoutServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        // odstranění uživatele ze session
        HttpSession session = req.getSession();
        session.removeAttribute("LOGGED_USER");
        
        // přesměrování na hlavní stránku
        res.sendRedirect("./home");
    }
}

Naše aplikace je hotová. Pokud jste si ji sami neprogramovali podle tohoto článku, tak si alespoň v následujícím slideru můžete prohlédnout jak vypadá, a jak ji uživatel může používat.

  • Ukázka testovací aplikace pro CSRF útok - část 1

    Na hlavní stránce (http://localhost:8080/example-app/home) má uživatel možnost se zaregistrovat.

  • Ukázka testovací aplikace pro CSRF útok - část 2

    Po kliknutí na "Zaregistrovat se" se uživateli zobrazí formulář pro registraci.

  • Ukázka testovací aplikace pro CSRF útok - část 3

    Uživatel formulář vyplní a odešle jej kliknutím na tlačítko "Zaregistrovat se".

  • Ukázka testovací aplikace pro CSRF útok - část 4

    Po registraci je uživatel automaticky přihlášen a zobrazí se mu jeho hlavní stránka.

  • Ukázka testovací aplikace pro CSRF útok - část 5

    Kliknutím na "Odhlásit se" se uživatel může odhlásit.

  • Ukázka testovací aplikace pro CSRF útok - část 6

    Po odhlášení se uživatel může přihlásit kliknutím na "Přihlásit se".

  • Ukázka testovací aplikace pro CSRF útok - část 7

    Uživateli se zobrazí formulář pro přihlášení.

  • Ukázka testovací aplikace pro CSRF útok - část 8

    Uživatel zadá správné přihlašovací údaje a kliknutím na "Přihlásit se" se přihlásí.

  • Ukázka testovací aplikace pro CSRF útok - část 9

    Na hlavní stránce uživatele se nachází možnost smazat účet.

  • Ukázka testovací aplikace pro CSRF útok - část 10

    Po kliknutí na možnost "Smazat účet" se uživateli zobrazí stránka, na které může potvrdit, že svůj účet chce opravdu smazat.

  • Ukázka testovací aplikace pro CSRF útok - část 11

    Po smazání účtu se uživateli zobrazí veřejná domovská stránka.

Vytvoření škodlivé webové stránky

Teď když máme připravenou testovací aplikaci, tak si vytvoříme webovou stránku, pomocí které tuto aplikaci napadneme. Pokud si ji přihlášený uživatel otevře, smaže se mu účet.

Založíme si nový Maven projekt (můžete jej pojmenovat třeba jako "bad-app") a jedinou věc kterou uděláme, je vytvoření JSP stránky s názvem "Home.jsp" přímo ve složce webapp, abychom nemuseli nic mapovat v souboru web.xml. Kód stránky ukazuje následující ukázka. Nachází se na ní formulář, který se posílá na servlet pro smazání účtu metodou POST. Tento formulář se navíc automaticky odesílá při načtení stránky přes JavaScript, takže opravdu stačí jen to, že uživatel webovou stránku navštíví.

  • src
    • main
      • java
      • resources
      • webapp
    • test
      • java
      • resources
  • target
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>bad-app</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>war</packaging>
    <name>bad-app</name>
</project>
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Bad app</title>
</head>
<body>
    <form id="form" method="POST" action="http://localhost:8080/example-app/delete-account"></form>
    <script>
        document.getElementById("form").submit();
    </script>
</body>
</html>

Smazání účtu přihlášeného uživatele

Aplikaci pro naši škodlivou stránku si nasadíme na jiný server, který spustíme na jiném portu (například na 9090). Pokud používáte Eclipse IDE, tak si v následujícím slideru můžete prohlédnout, jak to udělat.

  • Přidání druhého serveru do Eclipse IDE - část 1

    V záložce "Servers" klikneme pravým tlačítkem a vybereme že chceme vytvořit nový server.

  • Přidání druhého serveru do Eclipse IDE - část 2

    Vybereme co za server chceme vytvořit, nějak jej pojmenujeme a klikneme na "Finish".

  • Přidání druhého serveru do Eclipse IDE - část 3

    Na vytvořený server klikneme pravým tlačítkem a vybereme "Add and Remove...".

  • Přidání druhého serveru do Eclipse IDE - část 4

    Na server si přidáme aplikaci s naší škodlivou stránkou.

  • Přidání druhého serveru do Eclipse IDE - část 5

    Po přidání aplikace klikneme na "Finish".

  • Přidání druhého serveru do Eclipse IDE - část 6

    Na závěr si otevřeme nastavení serveru dvojtým klikem myši na serveru a přenastavíme port na kterém bude server běžet a také nastavíme admin port. Poté stiskneme ctrl + s pro uložení.

Server s testovací aplikací i server se škodlivou webovou stránkou si můžete spustit. V testovací aplikaci si můžete zkusit zaregistrovat uživatele a nechat jej přihlášeného. Poté můžete navštívit škodlivou webovou stránku a tomuto přihlášenému uživateli by se měl smazat účet. Pokud jste nastavili port serveru na 9090, tak bude škodlivá webová stránka k dispozici zde: http://localhost:9090/bad-app/Home.jsp.

Implementace ochrany proti CSRF útokům

Když jsme si teď ukázali, jak CSRF útok může vypadat, tak si také ukážeme, jak se proti němu bránit. Jak jsem již psal, používají se k tomu tzv. CSRF tokeny. Jedná se o unikátní hodnotu, která je pro každého uživatele vygenerována na serveru, a přidává se do každého formuláře, který na serveru provádí nějakou operaci (většinou jako skrytý input typu hidden). Když se formulář s CSRF tokenem odešle, tak se zjistí, zda je CSRF token validní a pokud ne, server požadovanou operaci neprovede. Škodlivá stránka se samozřejmě posílá ze serveru útočníka, který k CSRF tokenu uloženém v naší aplikaci nemá přístup, a nemůže jej tedy jen tak umístit do svého formuláře, směřující na náš server.

V naší aplikaci nám tedy stačí pro uživatele vygenerovat nějakou náhodnou hodnotu, uložit ji do session a při každém requestu o smazání účtu kontrolovat, zda se tato hodnota posílá a zda je správná. Následující ukázka ukazuje upravený kód servletu pro smazání účtu. Při načítání stránky (pro GET request) generujeme pro uživatele CSRF token (pokud jej ještě nemá vygenerovaný) a nastavujeme jej jako atribut do requestu, aby jej mohla JSP stránka přidat do formuláře. Při zpracovávání formuláře poté zjišťujeme, zda je CSRF token správný a můžeme uživateli účet smazat. Pokud správný není, tak posíláme chybovou stránku.

package com.example.servlets;

import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.example.model.User;
import com.example.model.UsersDatabase;

public class DeleteAccountServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        if (loggedUser == null) {
            res.sendRedirect("./home");
            return;
        }
        
        String csrfToken = (String) session.getAttribute("CSRF_TOKEN");
        
        // pokud CSRF token ještě pro uživatele není vygenerovaný,
        // tak se vygeneruje a uloží do session
        if (csrfToken == null) {
            try {
                csrfToken = generateToken();
                session.setAttribute("CSRF_TOKEN", csrfToken);
            } catch (NoSuchAlgorithmException e) {
                System.out.println(e);
            }
        }
        
        // nastavení CSRF tokenu do requestu, aby jej JSP stránka mohla přidat do formuláře
        req.setAttribute("CSRF_TOKEN", csrfToken);
        
        RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/DeleteAccountPage.jsp");
        dispatcher.forward(req, res);
    }
    
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        HttpSession session = req.getSession();
        User loggedUser = (User) session.getAttribute("LOGGED_USER");
        
        // pokud uživatel není přihlášen, tak je jen přesměrován na hlavní stránku
        if (loggedUser == null) {
            res.sendRedirect("./home");
            return;
        }
        
        // získání CSRF tokenu ze session
        String csrfToken = (String) session.getAttribute("CSRF_TOKEN");
        // získání CSRF tokenu z poslaného formuláře
        String passedCsrfToken = (String) req.getParameter("CSRF_TOKEN");
        
        // pokud CSRF token nebyl poslán, neshoduje se s CSRF tokenem uloženým v session nebo ještě
        // pro uživatele nebyl vygenerován, tak se namísto smazání účtu zobrazí chybová stránka
        if (
            csrfToken == null
            || passedCsrfToken == null
            || !csrfToken.equals(passedCsrfToken)
        ) {
            res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/jsp/BadCSRFTokenPage.jsp");
            dispatcher.forward(req, res);
            return;
        }
            
        
        // odstranění uživatele z databáze
        UsersDatabase.deleteUserById(req, loggedUser.getId());
        // odstranění uživatele ze session
        session.removeAttribute("LOGGED_USER");
        // přesměrování na hlavní stránku
        res.sendRedirect("./home");
    }
    
    // metoda pro vygenerování CSRF tokenu
    public static String generateToken() throws NoSuchAlgorithmException {
        SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
        byte[] data = new byte[16];
        secureRandom.nextBytes(data);

        return Base64.getEncoder().encodeToString(data);
    }
}

Upravenou JSP stránku "DeleteAccountPage.jsp", která teď do formuláře navíc přidává skrytý input s hodnotou CSRF tokenu ukazuje následující ukázka.

  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Smazat účet</h1>
    <p>Opravdu si přejete smazat svůj účet?</p>
    <form action="./delete-account" method="POST">
        <input type="hidden" name="CSRF_TOKEN" value="${CSRF_TOKEN}"/>
        <button type="submit">Smazat účet</button>
    </form>
</body>
</html>

Novou JSP stránku, která se zobrazuje když se pošle špatný CSRF token, ukazuje následující ukázka. Obsahuje pouze zprávu o tom, že se poslal špatný token.

  • src/main/webapp/WEB-INF/jsp
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Example app</title>
</head>
<body>
    <h1>Došlo k chybě</h1>
    <p>Byl poslán špatný CSRF token.</p>
</body>
</html>

Pokud si nyní v naší testovací aplikaci necháte přihlášeného uživatele a zkusíte navštívit škodlivou webovou stránku, tak se vám zobrazí stránka se zprávou, že byl poslán špatný CSRF token, ale účet se nesmaže. Úspěšně jsme tedy implementovali ochranu proti CSRF útokům.

Chybová stránka - špatný CSRF token

Možná vás napadlo to, že by si útočník mohl stránku pro smazání účtu uživatele stáhnout pomocí JavaScriptu a přečíst si z ní CSRF token, který by vložil do svého formuláře, než by jej odeslal. To ale defaultně udělat nemůže. Webové prohlížeče totiž implementují mechanismus, kterému se říká Cross-Origin Resource Sharing (CORS). Více si o tom můžete přečíst třeba na MDN. JavaScript jednoduše nemá přístup k odpovědím z requestů, které posílá na servery v jiné doméně. Museli by mu to dovolit pomocí HTTP headeru Access-Control-Allow-Origin. Buďte tedy při nastavování tohoto headeru opatrní a nenastavujte jej jen tak pro každého.

CSRF ochrana pro knihovnu Servlet Router

Pokud pro tvorbu webových aplikací používáte knihovnu Servlet Router, o které jsem psal v tomto článku, tak můžete pro implementaci ochrany proti CSRF útokům použít middleware, který jsem pro ni vytvořil.

Implementace CSRF ochrany pomocí tohoto middlewaru je velmi jednoduchá. Stačí si vytvořit podtřídu třídy CSRFProtection a implementovat metodu handleError. Tato metoda se volá, když se nepředá žádný nebo špatný CSRF token a můžete v ní tedy třeba poslat nějakou stránku s chybovou zprávou. Poté stačí middleware zaregistrovat někde na začátku vašeho hlavního routeru. Bude se starat o generování CSRF tokenu pro každou session, jeho nastavování jako atributu do requestu a dále také samozřejmě bude pro každý request poslaný HTTP metodou, která mění data, ověřovat, zda se CSRF token předává a zda je správný. Příklad vytvoření middlewaru a jeho zaregistrování ukazuje následující ukázka.

public class MyCSRFProtection extends CSRFProtection {
    @Override
    public void handleError(HttpServletRequest request, HttpServletResponse response) throws Exception {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
public class AppRouter extends HttpRouter {
    public AppRouter() {
        register(new BaseURLAttributeSetter());
        register(new MyCSRFProtection());
        register("/", HomeController.class);
        register("/games", new GamesRouter());
        registerErrorController(MyErrorController.class);
    }
}

CSRFProtection middleware nastavuje CSRF token jako atribut requestu, takže jej potom můžete v JSP stránkách přidávat do formulářů jako skrytý input, nebo nastavovat jako globální proměnnou pro JavaScript. Příklad ukazuje následující ukázka.

<form action="${BASE_URL}/login" method="POST">
    <input name="CSRF_TOKEN" value="${CSRF_TOKEN}" type="hidden">
    <input name="username" type="text" required>
    <input name="password" type="password" required>
    <button type="submit">Přihlásit se</button>
</form>
<script>
    // nastavení globální proměnné, kterou může použít
    // JavaScript kód, když se přes něj bude posílat request
    const CSRF_TOKEN = ${CSRF_TOKEN};
</script>

Defaultně jsou proti CSRF útokům chráněny pouze metody POST, PUT, PATCH a DELETE. U metod GET, HEAD, TRACE a OPTIONS se totiž předpokládá, že je nebudete používat pro žádné měnění dat na serveru. Formuláře posílané metodou GET by měli sloužit jen pro získání nějakých dat, takže nejsou z hlediska CSRF útoků nebezpečné. Pokud byste to ale chtěli změnit, tak si můžete sami nadefinovat, které HTTP metody mají být zabezpečené (nedoporučuji to).

public class MyCSRFProtection extends CSRFProtection {
    public MyCSRFProtection() {
        // pouze metody POST a PUT budou chráněné proti CSRF útokům
        super(new String[]{"POST", "PUT"});
    }
    
    @Override
    public void handleError(HttpServletRequest request, HttpServletResponse response) throws Exception {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

Pokud by vám nevyhovoval název atributu "CSRF_TOKEN", tak jej lze také změnit, jak ukazuje následující ukázka.

public class MyCSRFProtection extends CSRFProtection {
    public MyCSRFProtection() {
        super("MY_CSRF_TOKEN");
    }
    
    @Override
    public void handleError(HttpServletRequest request, HttpServletResponse response) throws Exception {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
<form action="${BASE_URL}/login" method="POST">
    <input name="MY_CSRF_TOKEN" value="${MY_CSRF_TOKEN}" type="hidden">
    <input name="username" type="text" required>
    <input name="password" type="password" required>
    <button type="submit">Přihlásit se</button>
</form>

Pokud byste chtěli změnit název atributu i chráněné metody zároveň, tak můžete do konstruktoru předat dva parametry, jak ukazuje následující ukázka.

public class MyCSRFProtection extends CSRFProtection {
    public MyCSRFProtection() {
        super("MY_CSRF_TOKEN", new String[]{"POST", "PUT"});
    }
    
    @Override
    public void handleError(HttpServletRequest request, HttpServletResponse response) throws Exception {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}