XSS (Cross-Site Scripting) es el tipo de vulnerabilidad web más común. Ocurre cuando una aplicación incluye en su salida HTML datos proporcionados por el usuario sin escaparlos correctamente, permitiendo que un atacante inyecte código JavaScript que se ejecuta en el navegador de otros usuarios. En PHP la protección se basa en escapar la salida según el contexto donde se inserta.
El problema: diferentes contextos, diferentes escapes
No existe una función universal de escape para XSS porque el peligro cambia según dónde se inserta el dato:
- Dentro de una etiqueta HTML:
<p>DATO</p>? usarhtmlspecialchars() - Dentro de un atributo HTML:
<input value="DATO">? usarhtmlspecialchars() - Dentro de un bloque JavaScript:
<script>var x = DATO;</script>? usarjson_encode() - Dentro de una URL:
<a href="DATO">? usarrawurlencode()
htmlspecialchars(): el escape HTML
<?php
$nombre = '<script>alert("XSS")</script>';
// MAL vulnerable
echo "<p>Hola, $nombre</p>";
// Renderiza el script en el navegador
// BIEN escapado correcto
echo "<p>Hola, " . htmlspecialchars($nombre, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</p>";
// Renderiza: Hola, <script>alert("XSS")</script>
// Siempre usa ENT_QUOTES para escapar también las comillas simples
// (necesario cuando el valor está dentro de un atributo con comillas simples)
$valor = "' onmouseover='alert(1)";
echo '<input value="' . htmlspecialchars($valor, ENT_QUOTES, 'UTF-8') . '">';
// <input value="' onmouseover='alert(1)">
// Función helper
function e(mixed $val): string {
return htmlspecialchars((string)$val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo "<p>" . e($nombre) . "</p>";
La trampa de los atributos de evento
El escapado HTML no protege dentro de los atributos on*. Directamente, no pongas datos de usuario en onclick, onload, etc.:
<?php
$id = $_GET['id']; // el atacante envía: 42;alert(1)
// MAL aunque escapes el HTML, en onclick el JS se ejecuta antes
echo '<button onclick="cargar(' . htmlspecialchars($id) . ')">Cargar</button>';
// htmlspecialchars no escapa punto y coma ni paréntesis
// BIEN pasa el dato como atributo data-* y léelo con JS
echo '<button data-id="' . htmlspecialchars((string)(int)$id, ENT_QUOTES, 'UTF-8') . '" class="btn-cargar">Cargar</button>';
// Mejor aún: castea a int si esperas un entero
echo '<button data-id="' . (int)$_GET['id'] . '">Cargar</button>';
json_encode() para JavaScript
Cuando necesitas pasar datos PHP a JavaScript, json_encode() escapa los caracteres especiales de JS:
<?php
$datos = [
'nombre' => 'Ana <"García">',
'mensaje' => "He dicho: 'hola'",
];
// MAL inyección JS directa
echo "<script>var datos = " . json_encode($datos) . ";</script>";
// Con los flags correctos es seguro para HTML, pero mejor así:
// BIEN JSON_HEX_TAG escapa < y > para que no rompan el contexto HTML
echo "<script>var datos = " . json_encode($datos, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) . ";</script>";
// O pásalo como atributo data- y léelo desde JS
echo '<div id="app" data-config="' . htmlspecialchars(json_encode($datos), ENT_QUOTES, 'UTF-8') . '"></div>';
// En JS: JSON.parse(document.getElementById('app').dataset.config)
rawurlencode() para URLs
<?php
$busqueda = 'teclado <mecánico>';
// MAL
echo '<a href="/buscar?q=' . $busqueda . '">Buscar</a>';
// BIEN doble escape: rawurlencode para la URL, htmlspecialchars para el atributo HTML
$url = '/buscar?q=' . rawurlencode($busqueda);
echo '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Buscar</a>';
// Nunca uses urlencode() para paths; usa rawurlencode()
// urlencode() convierte el espacio en '+' (solo válido en query strings)
// rawurlencode() lo convierte en '%20' (correcto para paths y query strings)
Content Security Policy (CSP) en PHP
La CSP es una cabecera HTTP que el navegador usa para bloquear recursos no autorizados, incluyendo scripts inline no esperados. Es una segunda capa de defensa:
<?php
// Nonce: valor aleatorio por petición para permitir scripts inline específicos
$nonce = base64_encode(random_bytes(16));
// Cabecera CSP con nonce
header("Content-Security-Policy: " .
"default-src 'self'; " .
"script-src 'self' 'nonce-{$nonce}'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: https:; " .
"font-src 'self'; " .
"frame-ancestors 'none';"
);
// En el HTML, el script inline solo se ejecuta si tiene el nonce correcto
// El atacante no puede conocer el nonce porque es aleatorio por petición
echo "<script nonce='{$nonce}'>
// Este script se ejecuta
document.getElementById('app').classList.add('cargado');
</script>";
echo "<script>
// Este NO se ejecuta no tiene nonce válido
// Aquí iría el XSS inyectado
</script>";
Antipatrones comunes
<?php
// ANTIPATRÓN 1: htmlentities() sin ENT_QUOTES
// htmlentities() con ENT_COMPAT no escapa comillas simples
echo '<input value='' . htmlentities($val, ENT_COMPAT) . ''>';
// Un valor como "' onblur='alert(1)" no se escapa correctamente
// ANTIPATRÓN 2: strip_tags() como única protección
$limpio = strip_tags($entrada);
// strip_tags no escapa atributos: <img src=x onerror="alert(1)"> ? el onerror persiste
// ANTIPATRÓN 3: solo validar entrada, no escapar salida
// La validación reduce superficie de ataque pero no sustituye el escape de salida
// ANTIPATRÓN 4: usar el mismo escape para todos los contextos
// htmlspecialchars() dentro de <script></script> no protege
echo "<script>var x = '" . htmlspecialchars($val) . "';</script>";
// Si $val contiene '', htmlspecialchars no lo escapa para JS
Lista de verificación para proteger una plantilla PHP
<?php
// Plantilla segura
$nombre = e($_GET['nombre'] ?? ''); // HTML
$redireccion = rawurlencode($_GET['url'] ?? '/'); // URL param
$config_js = json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP); // JS
?>
<!DOCTYPE html>
<html>
<head>
<script nonce="<?= $nonce ?>">
var config = <?= $config_js ?>;
</script>
</head>
<body>
<h1>Hola, <?= e($nombre) ?></h1>
<a href="/perfil?redir=<?= htmlspecialchars($redireccion, ENT_QUOTES, 'UTF-8') ?>">
Ver perfil
</a>
<input type="text" value="<?= e($nombre) ?>" data-id="<?= (int)$id ?>">
</body>
</html>
El XSS se previene principalmente escapando la salida según el contexto, no filtrando la entrada. La validación de entrada es una buena práctica adicional, pero el escape de salida es lo que realmente protege. Con htmlspecialchars() en HTML, json_encode() en JavaScript y rawurlencode() en URLs, más una cabecera CSP con nonce, cubres el 99% de los escenarios.
