La inyección SQL, el XSS y el CSRF parecen tres problemas distintos, pero dos de ellos comparten la misma raíz: un dato que llega de fuera termina interpretado como código. En la inyección SQL, el código es una consulta a la base de datos; en el XSS, es HTML o JavaScript que el navegador ejecuta. El CSRF funciona de otra manera: el navegador del usuario envía una petición legítima en apariencia que el usuario nunca quiso hacer.

Seguridad básica en PHP
Foto: Sai Kiran Anagani _imkiran — CC0, vía Wikimedia Commons

Este artículo trata cada caso con el mismo esquema: el código vulnerable, por qué falla y cómo se corrige. No hay recetas para atacar sitios ajenos; el objetivo es que reconozcas los patrones peligrosos en tu propio código y sepas reemplazarlos. Los ejemplos son para PHP 8.1 o posterior.

Si quieres profundizar después, OWASP, la fundación sin ánimo de lucro que publica material abierto sobre seguridad web, mantiene guías de prevención específicas para cada una de estas tres vulnerabilidades. Aquí nos quedamos con lo que se aplica directamente en PHP.

1. Inyección SQL

El código vulnerable

<?php
// VULNERABLE: no usar
$email = $_POST['email'];
$sql   = "SELECT id, nombre FROM usuarios WHERE email = '$email'";
$fila  = $pdo->query($sql)->fetch();

Por qué falla

El valor del formulario se pega dentro del texto SQL antes de enviarlo al motor. Si ese valor contiene una comilla simple, la comilla cierra la cadena antes de tiempo y todo lo que sigue se interpreta como parte de la consulta. A partir de ahí, quien controla el campo del formulario controla la condición WHERE, y según los permisos del usuario de la base de datos podría leer o modificar datos que no le corresponden.

Escapar a mano con addslashes() no es una solución fiable: depende del juego de caracteres de la conexión y basta un descuido en una sola consulta para dejar el hueco abierto.

La corrección

<?php
declare(strict_types=1);

$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

if (!is_string($email)) { // null si no se envió, false si no es un correo válido
    http_response_code(422);
    exit('Escribe un correo válido.');
}

$stmt = $pdo->prepare('SELECT id, nombre FROM usuarios WHERE email = :email');
$stmt->execute(['email' => $email]);
$fila = $stmt->fetch();

Hay dos capas. La validación descarta lo que no tiene forma de correo, lo que mejora los mensajes de error y reduce basura en la base de datos. Pero la protección real contra la inyección es la consulta preparada: el SQL y el dato viajan por separado y el motor nunca interpreta el dato como código. Si la conexión está configurada con PDO::ATTR_EMULATE_PREPARES => false y PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, mejor todavía. La configuración completa, con transacciones y manejo de errores, está en la guía de PDO en PHP: conexión, consultas preparadas y transacciones.

El caso que las consultas preparadas no cubren

Los marcadores solo sirven para valores. Los nombres de columnas, de tablas y la dirección del orden no pueden ir como parámetros, así que un listado con orden elegido por el usuario suele terminar con una concatenación. La defensa aquí es una lista blanca: el usuario elige entre opciones conocidas y tu código decide el texto exacto que va al SQL.

<?php
// El usuario nunca escribe el SQL: solo elige una clave conocida
$columna = match ($_GET['orden'] ?? '') {
    'precio' => 'precio',
    'fecha'  => 'creado_en',
    default  => 'nombre',
};
$direccion = ($_GET['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC';

$stmt = $pdo->prepare("SELECT id, nombre, precio FROM productos ORDER BY $columna $direccion LIMIT 50");
$stmt->execute();

match compara con identidad estricta (===), así que cualquier valor inesperado, incluido un array enviado con corchetes en la URL, cae en default. Si prefieres un array de opciones, usa in_array($valor, $permitidas, true) con el tercer parámetro en true; sin él la comparación es flexible y puede aceptar valores que no esperas. Tienes más detalles sobre esa función en arrays en PHP: funciones esenciales con ejemplos.

Una medida complementaria que no requiere código: que el usuario de la base de datos que usa tu aplicación tenga solo los permisos que necesita. Si la web solo lee y escribe en unas tablas concretas, no tiene por qué poder borrar tablas ni crear usuarios.

2. Cross-site scripting (XSS)

El código vulnerable

<?php // VULNERABLE: no usar ?>
<p>Resultados para: <?= $_GET['q'] ?></p>

<?php foreach ($comentarios as $c): ?>
    <div class="comentario"><?= $c['texto'] ?></div>
<?php endforeach; ?>

Por qué falla

Los dos bloques imprimen texto externo tal cual dentro del HTML. Si ese texto contiene etiquetas, el navegador las interpreta como parte de la página, y eso incluye etiquetas <script> o atributos de evento. El primer caso es un XSS reflejado: el contenido malicioso viaja en un enlace. El segundo es un XSS almacenado, más grave, porque el código queda guardado en tu base de datos y se ejecuta en el navegador de cada persona que abre la página. Con ese acceso, un script puede leer lo que la página muestra, enviar formularios en nombre del usuario o cambiar el contenido.

La corrección

Escapar al imprimir, no al guardar. En la base de datos se guarda el texto original; cada vez que se muestra en HTML se convierte con htmlspecialchars(), que transforma <, >, & y las comillas en entidades HTML. Una función corta con nombre fácil de escribir ayuda a que nadie la omita:

<?php
declare(strict_types=1);

function e(string|int|float|null $valor): string
{
    // ENT_QUOTES: escapa comillas dobles y simples (necesario en atributos)
    // ENT_SUBSTITUTE: reemplaza secuencias UTF-8 inválidas en lugar de devolver ''
    return htmlspecialchars((string) $valor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$q = $_GET['q'] ?? '';
$q = is_string($q) ? $q : ''; // ?q[]=... llegaría como array
?>
<p>Resultados para: <?= e($q) ?></p>

<?php foreach ($comentarios as $c): ?>
    <div class="comentario"><?= e($c['texto']) ?></div>
<?php endforeach; ?>

Desde PHP 8.1, htmlspecialchars() usa por defecto ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401. Aun así, conviene escribir los flags y la codificación de forma explícita: el código queda autodocumentado y se comporta igual si alguien lo ejecuta en una configuración distinta.

¿Y strip_tags()? Elimina etiquetas, pero no está pensada como defensa: no protege atributos y altera el texto del usuario. Si necesitas permitir algo de formato (negritas, enlaces), usa un formato intermedio como Markdown con una librería que genere HTML seguro, o un sanitizador de HTML mantenido, en lugar de filtros caseros.

Cada contexto necesita su tratamiento

htmlspecialchars() es correcto para el cuerpo del HTML y para atributos entre comillas. En otros lugares de la página no basta:

Dónde va el datoQué hacer
Texto dentro de una etiqueta (<p>, <td>)e($dato)
Valor de un atributoe($dato) y el atributo siempre entre comillas dobles
URL en href o srcValidar que el esquema sea http o https y después e()
Datos para JavaScriptjson_encode() dentro de un atributo data-*, escapado con e()
Dentro de <style>, nombres de etiquetas o atributos sin comillasNo colocar datos del usuario ahí

El caso de las URL merece atención: htmlspecialchars() no impide que un enlace use esquemas peligrosos como javascript:, porque esa palabra no contiene caracteres especiales. Por eso hay que comprobar el esquema antes:

<?php
function urlSegura(string $url): string
{
    $esquema = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    return in_array($esquema, ['http', 'https'], true) ? $url : '#';
}

$datos = ['usuario' => $nombre, 'avisos' => $totalAvisos];
?>
<a href="<?= e(urlSegura($perfil['web'])) ?>" rel="nofollow noopener">Sitio web</a>

<div id="panel" data-config="<?= e(json_encode($datos, JSON_THROW_ON_ERROR)) ?>"></div>
<script src="/js/panel.js"></script>
<!-- En panel.js: JSON.parse(document.getElementById('panel').dataset.config) -->

urlSegura() también rechaza rutas relativas como /perfil, porque no tienen esquema; si tu caso las necesita, trátalas como una excepción explícita. Por otro lado, pasar los datos por un atributo data-* en lugar de imprimirlos dentro de un bloque <script> evita tener que razonar sobre el escapado propio de JavaScript, y además es compatible con una política de seguridad de contenido estricta.

Una segunda capa: Content-Security-Policy

La cabecera Content-Security-Policy le dice al navegador de qué orígenes puede cargar scripts. Con una política que no permite scripts en línea, un fallo de escapado puntual tiene mucho menos impacto. No sustituye al escapado; lo respalda.

<?php
// Antes de enviar cualquier salida
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");

Esta política bloquea los scripts escritos dentro del HTML y los atributos como onclick. Antes de activarla en un sitio existente, pruébala con la cabecera Content-Security-Policy-Report-Only, que informa de las infracciones en la consola del navegador sin bloquear nada.

3. Cross-site request forgery (CSRF)

El código vulnerable

<?php
// VULNERABLE: cambia el correo de la cuenta solo comprobando la sesión
session_start();

if (!isset($_SESSION['usuario_id'])) {
    http_response_code(401);
    exit;
}

$stmt = $pdo->prepare('UPDATE usuarios SET email = :email WHERE id = :id');
$stmt->execute(['email' => $_POST['email'] ?? '', 'id' => $_SESSION['usuario_id']]);

Por qué falla

La consulta está preparada, así que no hay inyección SQL. El problema es otro: el script confía en que, si llega la cookie de sesión, la petición la hizo el usuario a propósito. Pero el navegador puede adjuntar las cookies de tu sitio a peticiones que se originan en otra página, por ejemplo un formulario oculto que se envía solo. El servidor ve una sesión válida y ejecuta el cambio. Si después alguien usa «recuperar contraseña» con el correo nuevo, la cuenta deja de pertenecer a su dueño.

La corrección: token por sesión y cookies SameSite

La defensa principal es un token secreto que se genera en el servidor, se guarda en la sesión y se incluye en cada formulario. Otra página puede hacer que el navegador envíe una petición, pero no puede leer el contenido de tu página, así que no conoce el token. Todo cabe en un archivo pequeño:

<?php
declare(strict_types=1);

function iniciarSesionSegura(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        return;
    }

    ini_set('session.use_strict_mode', '1'); // rechaza ids de sesión que el servidor no generó

    session_set_cookie_params([
        'lifetime' => 0,       // la cookie dura hasta cerrar el navegador
        'path'     => '/',
        'secure'   => true,    // solo por HTTPS
        'httponly' => true,    // JavaScript no puede leerla
        'samesite' => 'Lax',   // no se envía en peticiones POST desde otros sitios
    ]);

    session_start();
}

function tokenCsrf(): string
{
    if (!isset($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32)); // 64 caracteres hexadecimales
    }
    return $_SESSION['csrf'];
}

function verificarCsrf(): void
{
    $esperado = $_SESSION['csrf'] ?? '';
    $recibido = $_POST['csrf'] ?? '';

    // hash_equals compara en tiempo constante y exige dos strings
    if (!is_string($recibido) || $esperado === '' || !hash_equals($esperado, $recibido)) {
        http_response_code(403);
        exit('La solicitud no es válida. Recarga la página e inténtalo de nuevo.');
    }
}

En el formulario, el token va en un campo oculto, escapado como cualquier otro valor:

<form method="post" action="/cuenta/email">
    <input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
    <label for="email">Nuevo correo</label>
    <input type="email" id="email" name="email" required>
    <button type="submit">Guardar</button>
</form>

Y el script que procesa el cambio verifica el método y el token antes de tocar la base de datos:

<?php
iniciarSesionSegura();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

verificarCsrf();

// ...a partir de aquí, validar el correo y ejecutar el UPDATE preparado

Dos reglas que hacen que esto funcione: las peticiones GET nunca cambian datos (ni borrar, ni cerrar sesión, ni marcar como leído), y todas las acciones que modifican algo pasan por verificarCsrf(). Si tu aplicación tiene muchas rutas, colócala en un punto central, como el enrutador o un middleware, para que no dependa de que cada script se acuerde de llamarla.

Qué aporta el atributo SameSite

El atributo SameSite de una cookie le indica al navegador cuándo debe enviarla en peticiones que vienen de otro sitio:

ValorComportamientoUso típico
StrictNunca se envía desde otro sitio, ni siquiera al seguir un enlacePaneles de administración y banca
LaxSe envía al navegar con un enlace (GET), no en formularios POST ni peticiones de fondo desde otro sitioLa mayoría de sitios con sesión
NoneSe envía siempre; los navegadores exigen que también sea SecureContenido incrustado en otros dominios

Algunos navegadores ya tratan como Lax las cookies que no declaran el atributo, pero no todos ni en todas las situaciones, así que conviene declararlo siempre. Y Lax no reemplaza al token: no protege acciones que cambian datos por GET y depende del navegador del visitante. Para cookies propias distintas de la sesión, setcookie() acepta las mismas opciones:

<?php
setcookie('tema', 'oscuro', [
    'expires'  => time() + 60 * 60 * 24 * 30, // 30 días
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

Un punto que conecta los tres: el inicio de sesión

El formulario de acceso reúne casi todo lo anterior: consulta preparada, token CSRF y cookies seguras. Le falta una pieza, el almacenamiento de contraseñas. Nunca se guardan en texto plano ni con md5() o sha1(), que son funciones rápidas pensadas para otros usos. PHP incluye password_hash(), que genera un hash lento con sal aleatoria incluida, y password_verify() para comprobarlo:

<?php
declare(strict_types=1);

iniciarSesionSegura();
verificarCsrf();

$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$clave = $_POST['clave'] ?? '';

$usuario = false;
if (is_string($email) && is_string($clave)) {
    $stmt = $pdo->prepare('SELECT id, clave_hash FROM usuarios WHERE email = :email');
    $stmt->execute(['email' => $email]);
    $usuario = $stmt->fetch();
}

if ($usuario === false || !password_verify($clave, $usuario['clave_hash'])) {
    // Mismo mensaje en ambos casos: no reveles si el correo está registrado
    $error = 'Correo o contraseña incorrectos.';
} else {
    // Si PHP cambió el algoritmo por defecto, se actualiza el hash en este momento
    if (password_needs_rehash($usuario['clave_hash'], PASSWORD_DEFAULT)) {
        $pdo->prepare('UPDATE usuarios SET clave_hash = ? WHERE id = ?')
            ->execute([password_hash($clave, PASSWORD_DEFAULT), $usuario['id']]);
    }

    session_regenerate_id(true); // nuevo id de sesión tras autenticarse
    $_SESSION['usuario_id'] = (int) $usuario['id'];
    unset($_SESSION['csrf']);    // se generará un token nuevo para la sesión autenticada
}

La columna clave_hash debería ser VARCHAR(255): la documentación de PHP lo recomienda porque la longitud del hash puede crecer si cambia el algoritmo por defecto. session_regenerate_id(true) evita que un identificador de sesión conocido antes del login siga siendo válido después. Y validar que cada dato sea del tipo esperado antes de usarlo, como hacen los is_string() del ejemplo, es más sencillo si trabajas con tipos estrictos; lo explicamos en tipos, variables y strict_types en PHP 8.

Lista de revisión antes de publicar

Úsala como repaso rápido sobre tu propio proyecto, de la base de datos hacia el navegador:

  1. Base de datos: ninguna consulta concatena datos externos; todo pasa por prepare() y execute(). Las columnas y direcciones de orden salen de una lista blanca. El usuario de la base de datos tiene permisos mínimos.
  2. Conexión PDO: ERRMODE_EXCEPTION, EMULATE_PREPARES en false y charset=utf8mb4 en MySQL.
  3. Salida HTML: cada dato impreso pasa por e(); las URL de usuarios se validan por esquema; los datos para JavaScript van en atributos data-* con json_encode().
  4. Formularios: todos los que usan POST llevan token CSRF verificado con hash_equals(); ninguna ruta GET modifica datos.
  5. Sesión y cookies: Secure, HttpOnly y SameSite declarados; session.use_strict_mode activado; id de sesión regenerado al iniciar sesión.
  6. Contraseñas: password_hash() con PASSWORD_DEFAULT, columna de 255 caracteres y mensaje de error que no revela si el correo existe.
  7. Producción: display_errors desactivado y log_errors activado en php.ini, para que los mensajes técnicos vayan al log y no a la pantalla.

Ninguno de estos puntos requiere un framework ni librerías externas: todo está en el núcleo de PHP desde hace años. Lo difícil no es la técnica, sino aplicarla en cada consulta, cada plantilla y cada formulario sin excepciones.

Leave a comment