Tema
CSRF: peticiones falsificadas
CSRF (Cross-Site Request Forgery) engaña al navegador de un usuario con sesión iniciada para que envíe, sin darse cuenta, una petición a tu sitio: cambiar su correo, transferir dinero, borrar una cuenta. En esta lección aprenderás cómo funciona y cómo bloquearlo con tokens y cookies SameSite.
Concepto
Imagina que firmaste un poder notarial que dice "el banco acepta cualquier orden que llegue en un sobre con mi sello". Alguien te envía un sobre ya preparado, tú lo metes al buzón del banco sin abrirlo, y el banco lo ejecuta porque lleva tu sello.
En la web, el sello es la cookie de sesión. El navegador la adjunta automáticamente a toda petición hacia tu dominio, sin importar desde qué página se origina. Si un usuario con sesión iniciada en tubanco.com visita sitio-malo.com, esa página puede hacer que su navegador envíe un formulario a tubanco.com... con su cookie incluida.
html
<!-- Página del atacante: sitio-malo.com/gatitos.html -->
<h1>¡Mira estos gatitos!</h1>
<!-- Formulario oculto que apunta a TU sitio -->
<form id="f" action="https://tusitio.com/cuenta/cambiar-email" method="POST">
<input type="hidden" name="email" value="atacante@malo.com">
</form>
<script>
// Se envía solo al cargar la página
document.getElementById('f').submit();
</script>La víctima ve gatitos; mientras tanto, su correo cambió. Después el atacante usa "olvidé mi contraseña" y se queda con la cuenta.
XSS vs. CSRF
En XSS el atacante ejecuta código dentro de tu sitio. En CSRF no ejecuta nada en tu sitio: solo provoca que el navegador envíe una petición legítima en apariencia. Ojo: si tienes XSS, ninguna protección CSRF te salva, porque el script del atacante puede leer el token.
Las defensas
- Token anti-CSRF (sincronizador): un valor aleatorio y secreto que guardas en la sesión y metes en cada formulario. El sitio atacante no puede leerlo, así que no puede incluirlo.
- Cookies
SameSite=LaxoStrict: el navegador no envía la cookie en peticiones POST que vienen de otros sitios. - Usar los métodos HTTP correctamente: GET nunca debe modificar datos.
- Verificar la cabecera
Originen peticiones que modifican datos (defensa adicional).
Ejemplo: tokens CSRF en PHP puro
php
<?php
// Helper reutilizable para todo el proyecto
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
// 32 bytes aleatorios criptográficamente seguros → 64 caracteres hex
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_campo(): string
{
// Campo oculto listo para pegar dentro de cualquier <form>
return '<input type="hidden" name="_token" value="' . csrf_token() . '">';
}
function csrf_verificar(): void
{
$enviado = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
// hash_equals compara en tiempo constante (evita ataques de tiempo)
if (!is_string($enviado) || !hash_equals($_SESSION['csrf_token'] ?? '', $enviado)) {
http_response_code(419);
exit('La sesión expiró o la petición no es válida. Recarga la página.');
}
}php
<?php
session_start();
require 'csrf.php';
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verificar(); // ✅ primero, antes de tocar nada
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email) {
$stmt = $pdo->prepare('UPDATE usuarios SET email = ? WHERE id = ?');
$stmt->execute([$email, $_SESSION['usuario_id']]);
$mensaje = 'Correo actualizado';
}
}
?>
<form method="POST">
<?= csrf_campo() ?>
<input type="email" name="email" required>
<button>Guardar</button>
</form>Token en peticiones fetch
Para AJAX, imprime el token en una etiqueta meta y envíalo en una cabecera:
php
<meta name="csrf-token" content="<?= csrf_token() ?>">js
// En tu JavaScript del navegador
const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch('/api/favoritos.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token, // csrf_verificar() lo lee de HTTP_X_CSRF_TOKEN
},
body: JSON.stringify({ producto_id: 42 }),
});Ejemplo: CSRF en Express
El paquete csurf fue deprecado en 2022, así que no lo uses en proyectos nuevos. Alternativas: csrf-csrf (patrón double submit cookie) o implementarlo tú mismo con la sesión, que es sencillo:
js
import crypto from 'node:crypto';
// Genera (o reutiliza) un token por sesión y lo expone a las vistas
export function csrfToken(req, res, next) {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken; // disponible en EJS/Pug
next();
}
// Verifica el token en métodos que modifican datos
export function csrfVerificar(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const enviado = req.body?._token || req.get('X-CSRF-Token') || '';
const esperado = req.session.csrfToken || '';
const a = Buffer.from(enviado);
const b = Buffer.from(esperado);
// timingSafeEqual exige la misma longitud
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('Token CSRF inválido');
}
next();
}js
import express from 'express';
import session from 'express-session';
import { csrfToken, csrfVerificar } from './csrf.js';
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));
app.use(csrfToken);
app.use(csrfVerificar); // protege todas las rutas POST/PUT/PATCH/DELETE
app.post('/cuenta/cambiar-email', (req, res) => {
// Si llegamos aquí, el token era válido
res.send('Correo actualizado');
});html
<form method="POST" action="/cuenta/cambiar-email">
<input type="hidden" name="_token" value="<%= csrfToken %>">
<input type="email" name="email" required>
<button>Guardar</button>
</form>En Laravel ya viene hecho
Laravel verifica tokens CSRF en todas las rutas web automáticamente. Solo necesitas la directiva @csrf dentro de cada formulario. Si ves un error 419, es que falta el token.
Cookies SameSite
El atributo SameSite controla si la cookie viaja en peticiones originadas en otros sitios:
| Valor | Comportamiento | Uso |
|---|---|---|
Strict | Nunca se envía desde otro sitio, ni siquiera al seguir un enlace | Bancos, paneles de administración |
Lax | Se envía al navegar con enlaces (GET), no en POST, iframes ni fetch de otros sitios | Valor recomendado por defecto |
None | Siempre se envía (requiere Secure) | Widgets embebidos de terceros |
php
<?php
// PHP 7.3+: configurar la cookie de sesión antes de session_start()
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // solo por HTTPS
'httponly' => true, // invisible para JavaScript
'samesite' => 'Lax',
]);
session_start();Error común
Confiar solo en SameSite. Navegadores antiguos no lo respetan, Lax permite peticiones GET (y si tus GET modifican datos, estás expuesto) y un subdominio comprometido cuenta como "mismo sitio". Usa SameSite más tokens.
GET nunca modifica datos
html
<!-- ❌ Un enlace o una imagen pueden disparar esto desde cualquier sitio -->
<a href="/productos/borrar.php?id=5">Borrar</a>
<!-- El atacante solo necesita: <img src="https://tusitio.com/productos/borrar.php?id=5"> -->php
<!-- ✅ Borrar con POST + token -->
<form method="POST" action="/productos/borrar.php" onsubmit="return confirm('¿Seguro?')">
<?= csrf_campo() ?>
<input type="hidden" name="id" value="5">
<button>Borrar</button>
</form>Verificar Origin (capa extra)
js
// Middleware Express: rechaza POST cuyo Origin no sea el nuestro
const ORIGENES = ['https://tusitio.com'];
app.use((req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origen = req.get('Origin');
if (origen && !ORIGENES.includes(origen)) {
return res.status(403).send('Origen no permitido');
}
next();
});¿Y las APIs con tokens Bearer?
Si tu API se autentica con un token en la cabecera Authorization: Bearer ... (y no con cookies), no es vulnerable a CSRF, porque el navegador no adjunta esa cabecera automáticamente. El riesgo vuelve en cuanto guardas el token en una cookie.
Ejercicios
- ¿Por qué el atacante no puede simplemente leer el token CSRF de tu formulario con
fetchdesde su sitio? - Agrega protección CSRF a un formulario PHP de "eliminar cuenta" que hoy usa un enlace GET.
- Configura
express-sessioncon cookie segura ySameSiteestricto para un panel de administración.
Ver solución
Por la política del mismo origen: el navegador permite que
sitio-malo.comenvíe peticiones a tu dominio, pero no le deja leer la respuesta (salvo que tu CORS lo permita, ver CORS). Sin leer el HTML, no puede conocer el token.Formulario y procesamiento:
php
<?php
session_start();
require 'csrf.php';
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verificar();
$pdo->prepare('DELETE FROM usuarios WHERE id = ?')->execute([$_SESSION['usuario_id']]);
session_destroy();
header('Location: /adios.php');
exit;
}
?>
<form method="POST">
<?= csrf_campo() ?>
<button>Eliminar mi cuenta definitivamente</button>
</form>- Configuración:
js
app.use(session({
name: 'admin_sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 1000 * 60 * 30, // 30 minutos
},
}));Resumen
- CSRF aprovecha que el navegador envía las cookies automáticamente.
- Defiéndete con tokens aleatorios por sesión, comparados con
hash_equalsotimingSafeEqual. - Configura las cookies con
SameSite=Lax(oStrict),SecureyHttpOnly. - GET nunca modifica datos; borrar y editar van por POST/PUT/DELETE.
csurfestá deprecado: usacsrf-csrfo tu propio middleware.- Las APIs con token Bearer en cabecera no son vulnerables a CSRF.