Protecció CSRF
Com funciona la protecció contra atacs CSRF a Laravel: tokens, middleware, exclusions, integració amb JavaScript i SPAs.
Què és un atac CSRF?#
CSRF (Cross-Site Request Forgery) és un tipus d'atac on un lloc web maliciós enganya el navegador de l'usuari perquè enviï una petició a la teva aplicació sense el seu consentiment. L'atac aprofita el fet que el navegador envia automàticament les cookies de sessió amb cada petició al domini, independentment de quin lloc web l'hagi originada.
Imagina aquest escenari: un usuari ha iniciat sessió a la teva aplicació bancària a banc.example.com. Mentre navega per internet, visita un lloc web maliciós que conté un formulari ocult com aquest:
<!-- Lloc web maliciós -->
<form action="https://banc.example.com/transferencia" method="POST" id="attack">
<input type="hidden" name="compte_desti" value="atacant-123">
<input type="hidden" name="import" value="10000">
</form>
<script>document.getElementById('attack').submit();</script>Quan el navegador carrega aquesta pàgina, el JavaScript envia automàticament el formulari. Com que l'usuari té una sessió activa a banc.example.com, el navegador inclou les cookies de sessió amb la petició, i el servidor la processa com si fos legítima. L'usuari acaba de fer una transferència de 10.000 euros sense saber-ho.
Com funciona la protecció a Laravel#
Laravel protegeix contra CSRF generant un token únic i aleatori per a cada sessió d'usuari. Aquest token es desa a la sessió del servidor i s'inclou com a camp ocult en cada formulari. Quan el servidor rep una petició POST, PUT, PATCH o DELETE, compara el token enviat amb el formulari amb el token guardat a la sessió. Si no coincideixen (o si no hi ha token), la petició es rebutja amb un error 419 "Page Expired".
L'atacant no pot incloure el token correcte al seu formulari maliciós perquè no té accés a la sessió de l'usuari. El token canvia amb cada sessió, és aleatori i suficientment llarg per fer impossible endevinar-lo per força bruta.
El middleware VerifyCsrfToken s'aplica automàticament a totes les rutes web (les definides a routes/web.php) com a part del grup de middleware web. Les rutes API (definides a routes/api.php) no tenen protecció CSRF perquè utilitzen autenticació basada en tokens (Sanctum, Passport) en lloc de cookies de sessió.
Incloure el token als formularis#
La directiva @csrf de Blade genera un camp <input> ocult amb el token CSRF. Has d'incloure-la dins de cada formulari que faci una petició POST, PUT, PATCH o DELETE:
<form method="POST" action="/articles">
@csrf
<div>
<label for="title">Títol</label>
<input type="text" id="title" name="title" required>
</div>
<div>
<label for="body">Contingut</label>
<textarea id="body" name="body" required></textarea>
</div>
<button type="submit">Crear article</button>
</form>La directiva @csrf genera el següent HTML:
<input type="hidden" name="_token" value="eyJpdiI6IjF2...token_molt_llarg...">Si oblides incloure @csrf, la petició fallarà amb un error 419 "Page Expired". Això pot ser confús la primera vegada que passa, però és una bona senyal: significa que la protecció funciona correctament. Laravel prefereix rebutjar una petició legítima per falta de token que acceptar una petició potencialment maliciosa.
Method Spoofing#
Els formularis HTML només suporten els mètodes GET i POST. Si necessites enviar una petició PUT, PATCH o DELETE (que és habitual en aplicacions RESTful), Laravel proporciona la directiva @method que afegeix un camp ocult _method al formulari. El router de Laravel llegeix aquest camp i tracta la petició com si fos del mètode especificat:
{{-- Actualitzar un article (PUT) --}}
<form method="POST" action="/articles/{{ $article->id }}">
@csrf
@method('PUT')
<input type="text" name="title" value="{{ $article->title }}">
<textarea name="body">{{ $article->body }}</textarea>
<button type="submit">Actualitzar</button>
</form>
{{-- Eliminar un article (DELETE) --}}
<form method="POST" action="/articles/{{ $article->id }}">
@csrf
@method('DELETE')
<button type="submit" onclick="return confirm('Estàs segur?')">
Eliminar article
</button>
</form>La directiva @method('PUT') genera:
<input type="hidden" name="_method" value="PUT">Cal utilitzar sempre @csrf conjuntament amb @method, perquè la petició real segueix sent un POST (que requereix verificació CSRF). La directiva @method simplement indica a Laravel quin mètode HTTP hauria de considerar.
CSRF amb JavaScript#
Quan fas peticions AJAX des de JavaScript, no pots incloure un camp de formulari @csrf. En lloc d'això, Laravel busca el token a la capçalera HTTP X-CSRF-TOKEN. La convenció és incloure el token en una meta tag al <head> del document i configurar la llibreria HTTP perquè l'enviï automàticament:
{{-- resources/views/layouts/app.blade.php --}}
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- ... resta del head --}}
</head>Amb Axios#
Axios (inclòs per defecte a molts projectes Laravel) es pot configurar per enviar el token automàticament amb cada petició:
import axios from 'axios';
// Configuració global (normalment a bootstrap.js)
axios.defaults.headers.common['X-CSRF-TOKEN'] =
document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Ara totes les peticions Axios inclouen el token
axios.post('/articles', {
title: 'El meu article',
body: 'Contingut de l\'article.',
}).then(response => {
console.log('Article creat!');
}).catch(error => {
console.error('Error:', error.response.data);
});Amb Fetch API#
Si utilitzes l'API nativa fetch, has d'incloure el token manualment a cada petició:
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch('/articles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({
title: 'El meu article',
body: 'Contingut de l\'article.',
}),
})
.then(response => response.json())
.then(data => console.log(data));Cookie XSRF-TOKEN#
Laravel també estableix una cookie XSRF-TOKEN amb cada resposta que conté el token CSRF. Algunes llibreries HTTP (com Axios) llegeixen automàticament aquesta cookie i l'envien com a capçalera X-XSRF-TOKEN. Això és útil per a SPAs on el frontend i el backend estan al mateix domini:
// Axios llegeix automàticament la cookie XSRF-TOKEN
// i l'envia com a capçalera X-XSRF-TOKEN.
// No cal configurar res si uses Axios amb Laravel.
axios.post('/articles', { title: 'Nou article' });La diferència entre X-CSRF-TOKEN i X-XSRF-TOKEN és subtil: X-CSRF-TOKEN conté el token en text pla (llegit de la meta tag), mentre que X-XSRF-TOKEN conté el token xifrat (llegit de la cookie). Laravel accepta tots dos i els verifica correctament.
CSRF amb SPAs (Sanctum)#
Quan el frontend és una SPA (Single Page Application) que viu en un domini diferent al backend, la configuració CSRF requereix un pas addicional. Laravel Sanctum proporciona un endpoint /sanctum/csrf-cookie que estableix la cookie XSRF-TOKEN. La SPA ha de fer una petició GET a aquest endpoint abans de poder enviar peticions autenticades:
// 1. Obtenir la cookie CSRF
await axios.get('https://api.example.com/sanctum/csrf-cookie');
// 2. Ara pots fer login i peticions autenticades
await axios.post('https://api.example.com/login', {
email: 'joan@example.com',
password: 'password',
});
// 3. Les peticions posteriors inclouen automàticament la cookie
const articles = await axios.get('https://api.example.com/api/articles');Per a que això funcioni, cal configurar Sanctum perquè reconegui el domini de la SPA com a "stateful":
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
'localhost,localhost:3000,127.0.0.1,spa.example.com'
)),Excloure rutes de la protecció CSRF#
Algunes rutes no poden enviar un token CSRF: webhooks de serveis externs (Stripe, GitHub, Twilio), callbacks d'OAuth, notificacions de gateways de pagament, etc. Aquestes rutes necessiten ser excloses de la verificació CSRF.
A Laravel 11, les exclusions es configuren al fitxer bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/*',
'webhook/*',
'paypal/ipn',
]);
})Les rutes excloses accepten peticions POST, PUT, PATCH i DELETE sense token CSRF. Això és necessari perquè el servei extern (Stripe, per exemple) no té manera d'obtenir un token CSRF de la teva aplicació. Com que aquestes rutes no estan protegides per CSRF, has d'implementar un altre mecanisme de verificació per assegurar que les peticions són legítimes.
Verificar webhooks sense CSRF#
Els serveis que envien webhooks utilitzen altres mecanismes per verificar l'autenticitat de les peticions. El més comú és una signatura HMAC: el servei signa el cos de la petició amb un secret compartit, i la teva aplicació verifica la signatura:
// Verificar la signatura d'un webhook de Stripe
Route::post('stripe/webhook', function (Request $request) {
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\Exception $e) {
return response('Invalid signature', 400);
}
// Processar l'event...
return response('OK', 200);
});Errors CSRF comuns#
Error 419 "Page Expired"#
L'error 419 indica que el token CSRF no és vàlid. Les causes més comunes són:
- Token absent: has oblidat la directiva
@csrfal formulari. - Sessió expirada: l'usuari ha estat inactiu massa temps i la sessió ha expirat. El token antic ja no és vàlid.
- Cache del navegador: el navegador mostra una versió en cache de la pàgina amb un token antic.
- Múltiples pestanyes: l'usuari ha obert la mateixa pàgina en diverses pestanyes. Cada pestanya pot tenir un token diferent si la sessió es regenera.
Per millorar l'experiència d'usuari quan la sessió expira, pots personalitzar la pàgina d'error 419 creant una vista a resources/views/errors/419.blade.php:
{{-- resources/views/errors/419.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="text-center py-12">
<h1 class="text-2xl font-bold">La sessió ha expirat</h1>
<p class="mt-4 text-gray-600">
Per seguretat, la pàgina ha expirat. Si us plau, recarrega la pàgina i torna-ho a intentar.
</p>
<a href="{{ url()->current() }}" class="mt-6 inline-block btn btn-primary">
Recarregar pàgina
</a>
</div>
@endsectionCSRF amb formularis en modals AJAX#
Un patró comú és carregar formularis dins de modals via AJAX. Si el formulari es carrega dinàmicament, el token CSRF de la pàgina principal podria haver expirat. La solució és incloure un token fresc al formulari carregat o utilitzar la capçalera X-CSRF-TOKEN:
// Opció 1: Enviar el formulari del modal amb AJAX i la capçalera
document.querySelector('#modal-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: formData,
});
});Regeneració del token#
Laravel regenera automàticament el token CSRF quan l'usuari inicia o tanca sessió. Això impedeix que un token obtingut en una sessió anterior sigui reutilitzat en una sessió nova (session fixation attack).
Si necessites regenerar el token manualment (per exemple, després d'una acció sensible), pots fer-ho regenerant la sessió:
// Regenerar la sessió (i el token CSRF)
$request->session()->regenerate();
// Regenerar només el token CSRF
$request->session()->regenerateToken();La regeneració del token invalida qualsevol formulari que l'usuari tingui obert amb el token anterior. Això pot causar errors 419 si l'usuari té múltiples pestanyes obertes. Per a la majoria d'aplicacions, la regeneració automàtica al login/logout és suficient.