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)
- Mise en œuvre 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 et migrations Eloquent 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 fournisseur de services
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 commiter .env dans le contrôle de version
# .gitignore inclut déjà .env par défaut
# Utiliser .env.example avec des espaces réservés à la place
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 requise manquante : {$key}");
}
}
Application du HTTPS
// AppServiceProvider::boot() ou middleware
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php pour les proxys de confiance (équilibreurs de charge)
// Utiliser des plages IP spécifiques — * fait confiance à tout, permettant l'usurpation 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', ''),
// Émission de tokens avec des capacités
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// Validation des 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
// Par défaut, c'est 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 substitution super-admin
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // Accorde toutes les capacités
}
return null; // Revenir 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 d'abord vérifier l'email
}
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 les contrôleurs
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 ce post</span>
@endcannot
Autorisation par middleware
// Utiliser le 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 massive
// MAUVAIS : $guarded = [] permet à TOUTES les colonnes d'être assignées en masse
// 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 quels champs peuvent ê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 requête directement
User::create($request->all()); // VULNÉRABLE à l'assignation massive !
// 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 !
Casting d'attributs
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // Convertir en booléen empêche l'injection de chaîne
'settings' => 'array', // Automatiquement json_encode/json_decode
'metadata' => 'encrypted:array', // Cast crypté Laravel 11+
'password' => 'hashed', // Hachage automatique Laravel 10+ lors de l'assignation
];
}
Sécurité des modèles
final class User extends Authenticatable
{
// Masquer les attributs sensibles des réponses JSON/API
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// Ajouter uniquement des 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 soft deleted
use SoftDeletes;
// Empêcher N+1 en limitant le chargement différé (mode strict optionnel)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
Protection CSRF
Protection par défaut
// CSRF Laravel est activé 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 précaution)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// Exclure uniquement les routes qui ont une protection CSRF externe (webhooks, etc.)
protected $except = [
'stripe/*', // Les webhooks Stripe utilisent leur propre vérification de signature
// Éviter 'api/*' en bloc — les routes Sanctum stateful ont besoin de CSRF.
// Exclure uniquement les routes de webhook/endpoint spécifiques 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 : Échappé automatiquement 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écurisée du HTML
// Lorsque vous devez autoriser un peu de 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>{!! $sanitizedHtml !!}</div>
Sécurité API
Limitation du taux de requêtes
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
],
];
// Sur des routes spécifiques
Route::middleware('throttle:60,1')->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
// Limitation personnalisée
Route::middleware('throttle:10,1')->post('/login', [AuthController::class, 'login']);
// Api limiter nommé (config/app.php)
'api_limit' => [
'max_attempts' => 100,
'decay_minutes' => 1,
];
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, // Requis pour Sanctum
];
Validation des entrées API
// Utiliser des form requests pour la validation
public function store(StoreUserRequest $request): JsonResponse
{
$validated = $request->validated();
// ...
return response()->json(['user' => $user], 201);
}
// Valider les paramètres de requête
Route::get('/users', function (Request $request) {
$request->validate([
'per_page' => ['integer', 'min:1', 'max:100'],
'sort' => ['in:name,email,created_at'],
'order' => ['in:asc,desc'],
]);
// ...
});
// Valider les entrées JSON
Route::post('/webhook', function (Request $request) {
$request->validate([
'event' => ['required', 'string'],
'data' => ['required', 'array'],
]);
// ...
});
Configuration de déploiement
Sécurité de la base de données
// config/database.php
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true, // Mode strict SQL
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => true,
]) : [],
],
Sécurité du cache
// config/cache.php
'default' => env('CACHE_DRIVER', 'redis'), // redis > file
// Utiliser le préfixe pour le cache partagé
'prefix' => env('CACHE_PREFIX', 'laravel_app'),
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', 'laravel_app:'),
],
],
Sécurité des files d'attente
// config/queue.php
'default' => env('QUEUE_CONNECTION', 'redis'), // redis > database
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
// Limiter les tentatives de jobs
// app/Jobs/ProcessPodcast.php
public $tries = 3;
public $backoff = [10, 30, 60]; // Attente exponentielle
Surveillance de la sécurité
Journalisation
// config/logging.php
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
'ignore_exceptions' => false,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'error'),
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
],
],
Alertes de sécurité
// app/Exceptions/Handler.php
public function register(): void
{
$this->reportable(function (AuthenticationException $e) {
// Notifier l'administrateur
Log::channel('slack')->warning('Tentative de connexion échouée', [
'ip' => request()->ip(),
'email' => request()->input('email'),
'user_agent' => request()->userAgent(),
]);
});
$this->reportable(function (AuthorizationException $e) {
Log::channel('slack')->warning('Tentative d\'accès non autorisé', [
'user_id' => auth()->id(),
'route' => request()->route()->uri(),
'ip' => request()->ip(),
]);
});
}
Dépendances et correctifs
Audits de sécurité
# Analyser les vulnérabilités des dépendances
composer audit
# Mettre à jour les dépendances
composer update
# Vérifier la version de Laravel
php artisan --version
# Voir les mises à jour de sécurité
composer show --latest
Correctifs rapides
# Mettre à jour vers la dernière version mineure
composer update "laravel/framework" --with-all-dependencies
# Corriger une vulnérabilité spécifique
# Exemple : CVE-2023-XXXXX
composer update "vendor/package" --with-all-dependencies