Prevenir XSS en PHP: htmlspecialchars, contextos de escape y Content Security Policy

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> ? usar htmlspecialchars()
  • Dentro de un atributo HTML: <input value="DATO"> ? usar htmlspecialchars()
  • Dentro de un bloque JavaScript: <script>var x = DATO;</script> ? usar json_encode()
  • Dentro de una URL: <a href="DATO"> ? usar rawurlencode()

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP