Bonnes Pratiques de Sécurité Laravel
Directives de sécurité complètes pour les applications Laravel afin de se protéger contre les vulnérabilités courantes.
Quand Activer
- Configuration de l'authentification et de l'autorisation Laravel (Sanctum, Passport, Jetstream, Breeze)
- Implémentation des rôles utilisateur, permissions et politiques
- Configuration des paramètres de sécurité de production et des variables d'environnement
- Révision des applications Laravel pour les vulnérabilités de sécurité
- Déploiement d'applications Laravel en production
- Écriture de requêtes Eloquent et migrations sécurisées
Configuration de Production
Paramètres de Production Essentiels
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // CRITIQUE : Jamais true en production
'key' => env('APP_KEY'), // Doit être défini : php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// Vérifier que APP_KEY est défini au démarrage
// bootstrap/app.php ou un service provider
if (empty(config('app.key'))) {
throw new RuntimeException('APP_KEY n\'est pas défini. Exécutez : php artisan key:generate');
}
Sécurité du Fichier d'Environnement
# NE JAMAIS commit .env dans le contrôle de version
# .gitignore inclut déjà .env par défaut
# Utiliser plutôt .env.example avec des valeurs fictives
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# Valider les variables requises au démarrage
// Dans AppServiceProvider::boot()
$requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username'];
foreach ($requiredKeys as $key) {
if (empty(config($key))) {
throw new RuntimeException("Clé de configuration manquante : {$key}");
}
}
Forçage HTTPS
// AppServiceProvider::boot() ou middleware
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php pour les proxies de confiance (load balancers)
// Utiliser des plages IP spécifiques — * fait confiance à tous, permettant la falsification X-Forwarded-*
// AWS : '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
// Forcer HTTPS en production via un middleware
// app/Http/Middleware/ForceHttps.php
public function handle($request, Closure $next)
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
Authentification
Sanctum (Authentification par Token API)
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
)));
'expiration' => 60 * 24, // Expiration du token en minutes (null = jamais)
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
// Émettre des tokens avec des capacités
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// Valider les capacités sur les routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', function () {
// L'utilisateur doit avoir la capacité 'read'
abort_unless(Auth::user()->tokenCan('read'), 403);
// ...
})->middleware('abilities:read');
Route::post('/orders', function () {
// L'utilisateur doit avoir la capacité 'write'
abort_unless(Auth::user()->tokenCan('write'), 403);
// ...
})->middleware('abilities:write');
});
Sécurité des Mots de Passe
// config/hashing.php
// Défaut : bcrypt. Argon2id est plus fort.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // Augmenter pour un hachage plus fort
],
'argon' => [
'memory' => 65536,
'threads' => 4,
'time' => 4,
],
// Validation du mot de passe dans RegisterRequest
public function rules(): array
{
return [
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // Vérifie haveibeenpwned
],
];
}
// Limiter le taux de tentatives de connexion
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// Notifier l'utilisateur d'une connexion suspecte
$user->notify(new SuspiciousLoginNotification($request->ip()));
}
}
Gestion des Sessions
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
// Régénérer la session lors de la connexion
// App\Http\Controllers\Auth\AuthenticatedSessionController
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // CRITIQUE : empêche la fixation de session
return redirect()->intended(RouteServiceProvider::HOME);
}
// Invalider la session lors de la déconnexion
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
Autorisation
Gates
// App\Providers\AuthServiceProvider
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post): bool {
return $user->id === $post->user_id;
});
Gate::define('publish-post', function (User $user): bool {
return $user->role === 'editor' || $user->role === 'admin';
});
// Utiliser before() pour la surcharge super-admin
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // Accorde toutes les capacités
}
return null; // Retour aux vérifications normales
});
}
// Utilisation dans les contrôleurs
public function update(Request $request, Post $post): RedirectResponse
{
Gate::authorize('update-post', $post);
// Ou : $this->authorize('update-post', $post);
// Ou : abort_unless(Auth::user()->can('update-post', $post), 403);
// ...
}
Politiques
// App\Policies\PostPolicy
class PostPolicy
{
use HandlesAuthorization;
public function viewAny(?User $user): bool
{
return true; // Liste publique
}
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail(); // Doit vérifier l'email d'abord
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30;
}
public function restore(User $user, Post $post): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, Post $post): bool
{
return $user->role === 'super-admin';
}
}
// Enregistrer dans AuthServiceProvider
protected $policies = [
Post::class => PostPolicy::class,
];
// Utilisation dans le contrôleur
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', compact('post'));
}
// Utilisation dans Blade
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Modifier</a>
@endcan
@cannot('update', $post)
<span>Vous ne pouvez pas modifier cet article</span>
@endcannot
Autorisation par Middleware
// Utilisation du middleware dans les routes
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Route::get('/posts/create', [PostController::class, 'create'])
->middleware('can:create,App\Models\Post');
// Middleware d'autorisation personnalisé
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string $role): mixed
{
if (!$request->user() || $request->user()->role !== $role) {
abort(403, 'Non autorisé. Cette zone nécessite le rôle : ' . $role);
}
return $next($request);
}
}
// Enregistrer dans Kernel
protected $routeMiddleware = [
'role' => \App\Http\Middleware\CheckRole::class,
];
// Utilisation dans les routes
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Sécurité Eloquent
Protection contre l'Assignation de Masse
// MAUVAIS : $guarded = [] autorise l'assignation de TOUTES les colonnes
// NE JAMAIS utiliser $guarded = [] en production
// BON : Liste blanche des attributs fillable
final class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'avatar',
];
// NE JAMAIS ajouter 'role', 'is_admin', 'is_verified' ici
}
// BON : Contrôler explicitement les champs pouvant être remplis dans les requêtes
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create($request->safe()->only([
'name', 'email', 'phone', 'avatar'
]));
// $request->safe() utilise uniquement les données validées
// $request->only() N'EST PAS sûr seul sans règles de validation
}
// MAUVAIS : Créer un utilisateur avec les données de la requête directement
User::create($request->all()); // VULNÉRABLE à l'assignation de masse !
// MEILLEUR : Utiliser des DTO pour la création
$user = User::create($request->validated()); // Uniquement les champs validés
Prévention des Injections SQL
// BON : Eloquent paramètre automatiquement les requêtes
User::where('email', $userInput)->first();
User::whereRaw('email = ?', [$userInput])->first();
// BON : Query Builder paramètre également
DB::table('users')->where('email', $userInput)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$userInput]);
// MAUVAIS : Interpolation de chaîne brute
DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // VULNÉRABLE !
User::whereRaw("email = '{$userInput}'")->first(); // VULNÉRABLE !
// MAUVAIS : whereRaw/orderByRaw avec entrée non échappée
User::orderByRaw($userInput); // VULNÉRABLE !
User::groupByRaw($userInput); // VULNÉRABLE !
// MAUVAIS : DB::statement avec concaténation
DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // VULNÉRABLE !
Cast d'Attributs
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // Cast en booléen empêche l'injection de chaîne
'settings' => 'array', // Automatiquement json_encode/json_decode
'metadata' => 'encrypted:array', // Cast chiffré Laravel 11+
'password' => 'hashed', // Laravel 10+ auto-hachage à la définition
];
}
Sécurité des Modèles
final class User extends Authenticatable
{
// Cacher les attributs sensibles des réponses JSON/API
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// Ajouter uniquement les attributs calculés sécurisés
protected $appends = ['full_name']; // sûr
// NE JAMAIS ajouter de données calculées sensibles
}
final class Post extends Model
{
// Portée globale pour filtrer les enregistrements supprimés en douceur
use SoftDeletes;
// Empêcher N+1 en restreignant le chargement paresseux (mode strict optionnel)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
Protection CSRF
Protection par Défaut
// La protection CSRF de Laravel est activée par défaut via le middleware VerifyCsrfToken
// app/Http/Kernel.php (protected $middlewareGroups['web'])
// Tous les formulaires POST/PUT/PATCH/DELETE doivent inclure @csrf
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
<button type="submit">Créer</button>
</form>
Exclusion de Routes (Avec Prudence)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// Exclure uniquement les routes disposant d'une protection CSRF externe (webhooks, etc.)
protected $except = [
'stripe/*', // Les webhooks Stripe utilisent leur propre vérification de signature
// Éviter le 'api/*' générique — les routes Sanctum stateful ont besoin de CSRF.
// Exclure uniquement les routes spécifiques de webhook/endpoint sans état.
];
}
CSRF avec JavaScript
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Exemple Axios (Laravel est livré avec Axios)
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector(
'meta[name="csrf-token"]'
).getAttribute('content');
// Exemple Fetch
fetch('/posts', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
</script>
Prévention XSS
Sécurité des Templates Blade
{{-- SÛR : Auto-échappé par Blade --}}
{{ $userInput }}
{{-- DANGEREUX : Sortie brute — NE JAMAIS utiliser avec des entrées utilisateur --}}
{!! $userInput !!}
{{-- SÛR : Utiliser {!! !!} uniquement avec du contenu de confiance que vous contrôlez --}}
{!! $trustedHtmlFromYourServer !!}
{{-- BON : Utiliser des directives d'échappement spécifiques --}}
@js($data) {{-- Encodage JSON pour JavaScript --}}
@json($data) {{-- Encodage JSON dans les templates --}}
{{-- MAUVAIS : Entrée utilisateur directe dans du HTML brut --}}
<div>{!! $user->bio !!}</div> {{-- VULNÉRABLE si l'utilisateur fournit une bio --}}
Gestion Sûre du HTML
// Lorsque vous devez autoriser du HTML, utilisez une approche de liste blanche
use HTMLPurifier; // Nécessite : composer require ezyang/htmlpurifier
public function sanitizeHtml(string $dirty): string
{
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$purifier = new \HTMLPurifier($config);
return $purifier->purify($dirty);
}
// Dans Blade :
<div>{!! $sanitizedContent !!}</div>
Sécurité API
Authentification Sanctum d'API Stateful
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),
// api.php - Routes protégées
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// Protection contre les attaques par force brute
// app/Http/Kernel.php
'throttle:60,1' // 60 tentatives par minute
Sécurité CORS
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true, // Essentiel pour Sanctum
];
Sécurité des En-têtes de Réponse
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// En-tête Content-Security-Policy (CSP)
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';");
return $response;
}
}
// Enregistrer le middleware dans Kernel.php
protected $middlewareGroups = [
'web' => [
// ... autres middlewares
\App\Http\Middleware\SecurityHeaders::class,
],
'api' => [
'throttle:api',
\App\Http\Middleware\SecurityHeaders::class,
],
];
Déploiement Sécurisé
Vérifications Avant le Déploiement
# 1. Vérifier que APP_DEBUG est false
php artisan config:clear && php artisan config:cache
# 2. Forcer HTTPS
# Voir section : Forçage HTTPS
# 3. Vérifier que l'application n'utilise pas $guarded = []
grep -r "\\$guarded = \\[\\]" app/
# 4. Vérifier les sorties brutes dans les templates (entrées utilisateur)
grep -rn "{!!.*\\$user\\|{!!.*\\$request\\|{!!.*\\$input" resources/views/
# 5. Vérifier les interpollations SQL brutes
grep -rn "whereRaw.*\\. \\|orderByRaw.*\$" app/
# 6. Auditer les dépendances pour les vulnérabilités connues
composer audit
Configuration de l'Environnement de Production
# .env en production exigences minimales
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... # Généré via php artisan key:generate
DB_CONNECTION=mysql
DB_HOST=...
DB_PORT=3306
DB_DATABASE=...
DB_USERNAME=...
DB_PASSWORD=...
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true
SESSION_ENCRYPT=true
SANCTUM_STATEFUL_DOMAINS=votre-domaine.com,admin.votre-domaine.com
# Pour Sanctum, désactiver l'expiration
# SANCTUM_TOKEN_EXPIRATION=null
# Bloquer les invites de débogage Laravel
APP_DEBUG=false
Tables de Référence Rapide
| Vulnérabilité | Pratique de Base | Mécanisme de Protection Laravel |
|---|---|---|
| XSS | Jamais {!! !!} avec entrées utilisateur | Échappement automatique Blade, HTMLPurifier |
| CSRF | Inclure @csrf et en-tête X-CSRF-TOKEN | Middleware VerifyCsrfToken |
| Assignation de Masse | Ne jamais utiliser $guarded = [] | $fillable, $request->validated() |
| Injection SQL | Utiliser toujours des requêtes paramétrées | Échappement Eloquent, Query Builder |
| Session Fixation | Régénérer session lors de la connexion | $request->session()->regenerate() |
| Fuite de Données | Cacher les attributs sensibles | $hidden, $casts, $appends |
| Attaques par Force Brute | Limiter les tentatives de connexion | throttle middleware, rate limiting |
| En-têtes de Sécurité Manquants | Définir les en-têtes de réponse | Middleware personnalisé SecurityHeaders |
Annexe : Dépannage Rapide
Problème : "419 Page Expired"
- Cause : Token CSRF manquant ou expiré
- Correction :
<meta name="csrf-token" content="{{ csrf_token() }}">
// Ne pas oublier @csrf dans les formulaires
Problème : "Access Denied" à tort
- Vérifier les Gates et Politiques
- Vérifier la surcharge
before()(super-admin) - Vérifier l'enregistrement dans AuthServiceProvider
- Vérifier les paramètres de portée Global
Problème : Les tokens API Sanctum ne fonctionnent pas
- Vérifier les domaines stateful dans config/sanctum.php
- Vérifier la réponse du cookie Sanctum CSRF
- Vérifier l'en-tête
Accept: application/json