Kodokon kodokon.com

Seguridad web: XSS, CSRF e inyecciones

Desglosa XSS, CSRF y la inyección SQL para entender exactamente qué bloquean en realidad htmlspecialchars, los tokens y las consultas preparadas.

12 min · 3 preguntas

Abrir esta lección en Kodokon

Las tres grandes familias de vulnerabilidades web - XSS, CSRF e inyecciones - comparten la misma causa raíz: los datos proporcionados por el usuario acaban interpretándose como código (HTML, SQL o una petición legítima). Empecemos por XSS: si muestras un valor de $_GET sin escaparlo, un atacante puede inyectar una etiqueta <script> y ejecutar JavaScript en los navegadores de tus demás visitantes - robando sesiones, realizando acciones sin su conocimiento. Con ?comment=<script>alert(1)</script>, la primera salida de abajo ejecuta el script; la segunda lo neutraliza. Aquí importan dos flags: ENT_QUOTES convierte también las comillas simples (de lo contrario, un atributo delimitado por comillas simples sigue siendo inyectable), y ENT_SUBSTITUTE reemplaza las secuencias UTF-8 inválidas en lugar de devolver una cadena vacía. Desde PHP 8.1, ambos flags son por fin el valor por defecto, pero explicitarlos documenta tu intención y protege el código que se ejecuta en versiones anteriores.

PHP
<?php
declare(strict_types=1);

$comment = $_GET['comment'] ?? '';

echo '<div>' . $comment . '</div>';

function e(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

echo '<div>' . e($comment) . '</div>';
La primera salida es vulnerable, la segunda está correctamente escapada.

CSRF explota un hecho sencillo: el navegador adjunta automáticamente las cookies de sesión a cualquier petición hacia tu dominio, incluso una desencadenada desde un sitio de terceros. Un formulario hostil puede, por tanto, enviar /account/delete usando la sesión de la víctima. La defensa clásica es el token sincronizador: un secreto aleatorio almacenado en la sesión y exigido en cada petición que modifica datos. Un sitio de terceros no puede ni leerlo ni adivinarlo, por lo que no puede proporcionarlo.

HTML
<?php
session_start();

if (!isset($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
    && hash_equals($_SESSION['csrf'], $sent);

$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
    http_response_code(403);
    exit('Invalid CSRF token');
}
?>
<form method="post">
  <input type="hidden" name="csrf"
    value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button>Delete my account</button>
</form>
Token generado una vez por sesión, verificado antes de cualquier acción que modifique datos.

La inyección SQL sigue el mismo patrón: concatenar un valor del usuario en una consulta equivale a dejar que el usuario escriba SQL - ' OR 1=1 -- convierte un filtro en la tabla entera. La solución no es escapar, sino la consulta preparada: el texto de la consulta (con sus marcadores :email) y los valores viajan por separado hasta el motor, que por tanto nunca puede confundir los datos con el código. Un detalle de experto: con MySQL, PDO emula las consultas preparadas por defecto, escapando en el lado del cliente; establece PDO::ATTR_EMULATE_PREPARES en false para tener consultas preparadas realmente nativas.

PHP
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$email = $_GET['email'] ?? '';

$stmt = $pdo->prepare(
    'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);

var_dump($stmt->fetch(PDO::FETCH_ASSOC));
El valor nunca se une al texto SQL: la inyección es imposible.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Por qué pasar el flag ENT_QUOTES a htmlspecialchars?
    • Para convertir también las comillas simples, sin las cuales un atributo delimitado por comillas simples sigue siendo inyectable
    • Para forzar la conversión de la cadena a UTF-8
    • Para eliminar las etiquetas script del contenido
    • Para acelerar el escape de cadenas largas
  2. ¿Por qué comparar el token CSRF con hash_equals en lugar de ===?
    • hash_equals compara en tiempo constante, lo que impide adivinar el token byte a byte midiendo los tiempos de respuesta
    • hash_equals recalcula automáticamente el hash del token antes de comparar
    • === falla en cuanto las dos cadenas tienen codificaciones diferentes
  3. ¿Qué hace que una consulta preparada sea inmune a la inyección SQL?
    • Escapa automáticamente las comillas que contienen los valores
    • La consulta y los valores se envían por separado al motor, que nunca reinterpreta los valores como SQL
    • Comprueba que cada valor coincide con el tipo de la columna de destino
    • Restringe la ejecución a una sola sentencia SQL