Saltar al contenido
seguridad.php · devschool

Seguridad en PHP

Lección 23 de 26 · 14 min de lectura · Actualizado el

En esta lección
  1. Validar entradas, escapar salidas
  2. XSS: código ajeno en tu página
  3. Inyección SQL: tus datos convertidos en consulta
  4. Contraseñas: nunca en texto plano
  5. CSRF: peticiones hechas en tu nombre
  6. Sesiones seguras
  7. Subida de archivos segura
  8. Errores: muéstralos solo en desarrollo
  9. Lista de comprobación
  10. Errores frecuentes
  11. Resumen

Una web que funciona no es una web segura. En cuanto publicas una página con formularios, cualquiera en internet puede enviarle lo que quiera: texto con código, contraseñas de prueba, archivos disfrazados. La regla de oro es una sola: nunca te fíes de los datos que llegan del exterior (formularios, URL, cookies, archivos subidos, cabeceras). En esta lección verás los ataques más habituales contra una aplicación PHP y, para cada uno, la defensa que se usa hoy: XSS, inyección SQL, contraseñas mal guardadas, CSRF, sesiones robadas, subidas peligrosas y errores que revelan demasiado.

Varias de estas defensas ya han aparecido de pasada en otras lecciones: validar formularios, sesiones y cookies, ficheros y subidas y PDO. Aquí las reunimos con el por qué de cada una y añadimos las que faltan.

Validar entradas, escapar salidas

Hay dos momentos en los que proteges los datos, y no se sustituyen:

MomentoQué hacesEjemplo
Al recibirValidar: comprobar que el dato es lo que esperasUn teléfono tiene 9 dígitos
Al usarloEscapar o parametrizar según el destinohtmlspecialchars en HTML, consulta preparada en SQL

Cada destino tiene sus propios caracteres peligrosos: lo que es inofensivo en HTML puede ser un ataque en SQL. Por eso se protege en el sitio donde se usa el dato, no una sola vez al entrar.

XSS: código ajeno en tu página

El XSS (Cross-Site Scripting) ocurre cuando una web imprime texto de un usuario sin escapar y el navegador lo ejecuta como código. Es el fallo más común del libro de visitas, del buscador y de los comentarios:

<?php
// MAL: imprime lo que llegue tal cual
echo "<p>Resultados para: {$_GET['q']}</p>";

Si alguien comparte un enlace con ?q=<script>...</script>, ese script se ejecuta en el navegador de quien lo abra, con acceso a la página y a sus datos. La defensa es escapar al imprimir con htmlspecialchars(), que convierte <, >, & y las comillas en entidades:

<?php
$comentario = '<script>alert("hola")</script>';
echo htmlspecialchars($comentario);
// &lt;script&gt;alert(&quot;hola&quot;)&lt;/script&gt;

Para no escribirlo cada vez, crea un atajo:

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

echo '<p>Resultados para: ', e($_GET['q'] ?? ''), '</p>';
echo '<input name="busqueda" value="', e($_GET['q'] ?? ''), '">';

Reglas prácticas:

  • Escapa todo lo que no hayas escrito tú, venga de un formulario, de la base de datos o de una API.
  • Escapa al imprimir, no al guardar. En la base de datos va el texto original.
  • Pon comillas en los atributos (value="..."): con ENT_QUOTES, las comillas del dato quedan escapadas y no pueden cerrar el atributo.
  • En URLs, usa urlencode() para cada parámetro, y no pongas en un href una dirección de usuario sin comprobar que empieza por https:// o http:// (un javascript:... también ejecuta código).

Nota: cuando un campo debe permitir algo de formato (negritas, enlaces), no intentes filtrar HTML a mano. Usa un formato como Markdown o una librería de saneado especializada.

Inyección SQL: tus datos convertidos en consulta

Si construyes una consulta pegando texto del usuario, esa persona puede escribir SQL. Veámoslo con una tabla de usuarios y un buscador. Usamos SQLite en memoria para poder probarlo sin servidor:

<?php
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nombre TEXT, rol TEXT)");
$pdo->exec("INSERT INTO usuarios (nombre, rol) VALUES ('marta', 'socia'), ('admin', 'admin')");

$nombre = "x' OR '1'='1";   // lo que escribe un atacante en el campo
$sql = "SELECT nombre, rol FROM usuarios WHERE nombre = '$nombre'";
echo $sql, "\n";
// SELECT nombre, rol FROM usuarios WHERE nombre = 'x' OR '1'='1'

print_r($pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC));
// Devuelve dos filas: marta (socia) y admin (admin)

La comilla del atacante cierra la cadena y OR '1'='1' hace la condición siempre verdadera: la consulta devuelve a todos los usuarios. Con otros trozos de SQL podría leer otras tablas, borrar datos o saltarse un login.

La defensa es la consulta preparada, que ya conoces de PDO: la consulta y los datos viajan por separado y el valor nunca se interpreta como SQL.

<?php
$consulta = $pdo->prepare('SELECT nombre, rol FROM usuarios WHERE nombre = ?');

$consulta->execute(["x' OR '1'='1"]);
var_dump($consulta->fetchAll());   // array(0) { }  (nadie se llama así)

$consulta->execute(['marta']);
print_r($consulta->fetch(PDO::FETCH_ASSOC));
// Array ( [nombre] => marta [rol] => socia )

El mismo texto malicioso es ahora solo un nombre raro que no existe. Algunas precisiones:

  • Siempre que haya un dato externo, usa prepare() y execute(). También en UPDATE, DELETE e INSERT.
  • Escapar a mano no es la solución. Funciones antiguas como addslashes() o mysqli_real_escape_string() fallan en casos que las consultas preparadas resuelven por diseño.
  • Los nombres de tablas y columnas no se pueden parametrizar. Si el usuario elige “ordenar por”, compara con una lista permitida (in_array($campo, ['nombre', 'km'], true)) y construye la consulta con el valor de esa lista, nunca con el del usuario.
  • Con MySQL, desactiva las preparaciones emuladas (PDO::ATTR_EMULATE_PREPARES => false), como viste en la conexión con PDO, para que las consultas se preparen de verdad.
  • El usuario de la base de datos de tu web debe tener los permisos mínimos (por ejemplo, sin DROP ni acceso a otras bases de datos).

Contraseñas: nunca en texto plano

Guardar una contraseña tal cual significa que, el día que alguien robe tu base de datos, tendrá las claves de todos tus usuarios, que probablemente las usan en otros sitios. Se guarda un hash: el resultado de una función de un solo sentido. Para contraseñas, PHP trae password_hash() y password_verify():

<?php
$hash = password_hash('Escalada2026', PASSWORD_DEFAULT);
echo strlen($hash), ' ', substr($hash, 0, 7), "\n";   // 60 $2y$12$

var_dump(password_verify('Escalada2026', $hash));      // bool(true)
var_dump(password_verify('otra', $hash));              // bool(false)

Por qué es la forma correcta:

  • Usa un algoritmo deliberadamente lento (bcrypt por defecto) para que probar millones de claves sea inviable.
  • Añade una sal aleatoria a cada hash: dos usuarios con la misma contraseña tienen hashes distintos. El hash lleva dentro la sal y el coste, así que password_verify no necesita nada más.
  • PASSWORD_DEFAULT cambiará de algoritmo cuando se recomiende uno mejor.

Compara con las funciones antiguas: md5('Escalada2026') da siempre e898af24add5a206cbbc4390e1eabe47, es rapidísima y existen tablas con miles de millones de resultados ya calculados. No uses md5(), sha1() ni hash('sha256', ...) para contraseñas.

Buenas prácticas:

  • Columna de al menos 255 caracteres (VARCHAR(255)) para el hash: el algoritmo puede cambiar y alargarse.
  • Nunca compares hashes con ==. Usa password_verify.
  • Actualiza hashes viejos al iniciar sesión, con password_needs_rehash:
<?php
if (password_verify($clave, $hashGuardado)) {
    if (password_needs_rehash($hashGuardado, PASSWORD_DEFAULT)) {
        $nuevoHash = password_hash($clave, PASSWORD_DEFAULT);
        // UPDATE usuarios SET clave = ? WHERE id = ?
    }
}
  • Mismo mensaje para usuario y clave incorrectos (“Datos incorrectos”): así no ayudas a descubrir qué usuarios existen.
  • Limita los intentos (bloqueo temporal o espera creciente) para frenar la fuerza bruta.

CSRF: peticiones hechas en tu nombre

Imagina que estás conectado a la web de tu gimnasio y abres otra pestaña con una página maliciosa. Esa página contiene un formulario oculto que apunta a https://gimnasio.example/baja.php y se envía solo. Tu navegador adjunta tu cookie de sesión y el servidor cree que eres tú quien pide la baja. Ese ataque es el CSRF (Cross-Site Request Forgery).

La defensa clásica es un token CSRF: un valor aleatorio que el servidor guarda en tu sesión y pone en un campo oculto del formulario. Al recibir el POST, comprueba que el token enviado coincide con el guardado. Una página ajena no puede conocerlo.

<?php
function token_csrf(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));   // 64 caracteres hexadecimales
    }
    return $_SESSION['csrf'];
}

function csrf_valido(?string $recibido): bool
{
    return is_string($recibido)
        && isset($_SESSION['csrf'])
        && hash_equals($_SESSION['csrf'], $recibido);
}
  • random_bytes() genera bytes aleatorios criptográficamente seguros. No uses rand() ni uniqid() para esto: son predecibles.
  • hash_equals() compara dos textos en tiempo constante, para que un atacante no deduzca el token midiendo cuánto tarda la comparación.

En el formulario, el token va como campo oculto:

<form method="post" action="baja.php">
  <input type="hidden" name="csrf" value="<?= e(token_csrf()) ?>">
  <button>Darme de baja</button>
</form>

Y al recibir el POST lo compruebas antes de hacer nada:

<?php
// baja.php
session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_valido($_POST['csrf'] ?? null)) {
    http_response_code(403);
    exit('Petición no válida.');
}
// aquí ya es seguro continuar

Reglas del CSRF:

  • Toda acción que cambia datos (borrar, editar, comprar, cerrar sesión) debe hacerse por POST, nunca por un enlace GET, y llevar token.
  • Un token por sesión basta, aunque conviene renovarlo tras el login.
  • La cookie con SameSite=Lax (siguiente apartado) añade una segunda barrera: el navegador no envía la cookie en un POST que viene de otra web.

Sesiones seguras

La sesión se identifica con una cookie (PHPSESSID): quien la tenga, es el usuario. Cuida tres cosas.

1. Cambiar el identificador al iniciar sesión

Un ataque llamado fijación de sesión consiste en que el atacante fuerza a la víctima a usar un identificador que él ya conoce; cuando la víctima inicia sesión, el atacante entra con ese mismo identificador. Se evita generando uno nuevo en el momento del login:

<?php
if (password_verify($clave, $hash)) {
    session_regenerate_id(true);                     // nuevo ID; true borra el antiguo
    $_SESSION['usuario'] = 'marta';
    $_SESSION['csrf'] = bin2hex(random_bytes(32));   // y token nuevo
}

Haz lo mismo cuando cambien los privilegios del usuario (por ejemplo, al pasar a administrador).

<?php
session_set_cookie_params([
    'lifetime' => 0,          // se borra al cerrar el navegador
    'path'     => '/',
    'secure'   => true,       // solo por HTTPS (false en http://localhost)
    'httponly' => true,       // JavaScript no puede leerla: protege del robo vía XSS
    'samesite' => 'Lax',      // no se envía en POST desde otras webs
]);
session_start();
OpciónQué evita
httponlyQue un XSS robe la cookie con document.cookie
secureQue la cookie viaje sin cifrar
samesiteQue otras webs aprovechen tu cookie (CSRF)

Además, en el php.ini conviene session.use_strict_mode = 1, para que PHP rechace identificadores que él no ha creado, y usar siempre HTTPS en producción.

3. Cerrar la sesión bien

Al cerrar sesión, vacía los datos, destruye la sesión y borra la cookie. El código completo está en sesiones y cookies. Si quieres, caduca también la sesión tras un rato de inactividad.

Cuidado: nunca pases el identificador de sesión por la URL (?PHPSESSID=...). PHP ya lo evita por defecto con session.use_only_cookies; no lo desactives.

Subida de archivos segura

Una subida mal protegida permite que alguien suba foto.php y la visite para ejecutar su código en tu servidor. Las defensas, resumidas (el ejemplo completo está en ficheros y subidas):

  1. Comprueba $_FILES['campo']['error'] antes de nada.
  2. Limita el tamaño en tu código.
  3. Detecta el tipo real por el contenido, no por la extensión ni por lo que dice el navegador. Aquí, un archivo PHP disfrazado de .jpg y una imagen PNG de verdad:
<?php
$finfo = new finfo(FILEINFO_MIME_TYPE);
echo $finfo->file('/tmp/falsa.jpg'), "\n";   // text/x-php
echo $finfo->file('/tmp/ok.png'), "\n";      // image/png
  1. Usa una lista permitida de tipos y decide tú la extensión.
  2. Genera un nombre nuevo y aleatorio (bin2hex(random_bytes(16))). El nombre original puede contener ../ o llamarse x.php. Si lo necesitas para mostrarlo, guárdalo aparte y escápalo. Con basename() te quedas solo con el último trozo: basename('../../etc/passwd') devuelve passwd.
  3. Guarda fuera de la carpeta pública si es posible y, si no, configura el servidor para que no ejecute PHP en la carpeta de subidas.
  4. Para imágenes, comprueba además que getimagesize() no devuelve false.

Errores: muéstralos solo en desarrollo

Un mensaje de error de PHP o de PDO puede revelar rutas del servidor, nombres de tablas e incluso datos de conexión. En tu ordenador quieres ver los errores; en la web pública, no:

Entornodisplay_errorslog_errors
Desarrollo (tu equipo)OnOn
Producción (web pública)OffOn

En php.ini:

display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
error_reporting = E_ALL

El usuario ve una página de error genérica (“Algo ha fallado”) y tú lees los detalles en el registro. Con PDO, captura la excepción y no muestres $e->getMessage() en la página:

<?php
try {
    $pdo = new PDO($dsn, $usuario, $clave, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
} catch (PDOException $e) {
    error_log('Fallo de conexión: ' . $e->getMessage());   // al registro
    http_response_code(500);
    exit('Ahora mismo no podemos atenderte. Inténtalo más tarde.');
}

Más sobre esto en excepciones. Y, antes de publicar, elimina phpinfo() y los archivos de prueba (info.php, prueba.php, copias .bak).

Lista de comprobación

Antes de dar por buena una página, repasa:

  1. ¿Todo dato externo que imprimo pasa por e() o htmlspecialchars?
  2. ¿Todas mis consultas con datos usan prepare y execute?
  3. ¿Guardo las contraseñas con password_hash?
  4. ¿Los formularios que cambian algo van por POST con token CSRF?
  5. ¿Regenero el ID de sesión al iniciar sesión y la cookie es httponly y samesite?
  6. ¿Las subidas validan tipo real, tamaño y nombre?
  7. ¿display_errors está en Off en producción?

Errores frecuentes

  • Imprimir con echo sin escapar. Es el origen del XSS. Usa e() siempre.
  • Concatenar variables en la consulta SQL. Aunque “ya validé el dato”, usa una consulta preparada.
  • Usar md5() o sha1() para contraseñas. Son demasiado rápidas; usa password_hash.
  • Olvidar el token en un formulario o comprobarlo con == en lugar de hash_equals().
  • No regenerar el ID de sesión al iniciar sesión. Deja la puerta abierta a la fijación de sesión.
  • Fiarse de $_FILES['x']['type'] o de la extensión. Los decide el usuario; detecta el tipo por el contenido.
  • Dejar display_errors = On en producción. Regala información a los atacantes.
  • Pensar que validar en el navegador (HTML o JavaScript) basta. Cualquiera puede saltárselo; valida siempre en el servidor.

Resumen

AmenazaDefensaHerramienta
XSSEscapar al imprimirhtmlspecialchars, urlencode
Inyección SQLConsultas preparadas, lista permitidaprepare y execute
Contraseñas robadasHash lento con salpassword_hash, password_verify
CSRFToken por sesión y POSTrandom_bytes, hash_equals
Robo y fijación de sesiónID nuevo al entrar y cookie segurasession_regenerate_id, httponly, secure, samesite
Subidas peligrosasTipo real, nombre aleatorio, carpeta segurafinfo, move_uploaded_file
Errores que filtran datosMostrar solo en desarrollodisplay_errors, log_errors

En la siguiente lección verás cómo hablar con otros servicios y devolver datos con JSON y APIs.

Pon a prueba lo que has aprendido

[PHP] ¿Qué devuelve esta consulta si el atacante escribe x' OR '1'='1 como nombre?
$nombre = $_POST['nombre'];
$sql = "SELECT * FROM usuarios WHERE nombre = '$nombre'";
$filas = $pdo->query($sql)->fetchAll();

[PHP] ¿Qué imprime este código en PHP 8.4?
echo htmlspecialchars('<b>"hola"</b>');

[PHP] ¿Por qué se compara el token CSRF con hash_equals() en lugar de ===?

[PHP] ¿En qué momento hay que llamar a session_regenerate_id(true) para evitar la fijación de sesión?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.