Sécurité Laravel : ce que j'ai appris en sécurisant mon portfolio

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
14 min de lecture
663 vues

Écouter l'article

Sécurité Laravel : ce que j'ai appris en sécurisant mon portfolio

Une lecture des logs d'accès d'un site public montre vite le même bruit de fond : des requêtes du type ' OR 1=1 -- dans un champ de formulaire, des rafales de tentatives sur l'URL de login. Rien de sophistiqué, du script automatisé qui scanne des milliers de sites. Mais cela rappelle qu'un audit de ses propres points d'entrée n'est pas un luxe.

Cet article reprend, point d'entrée par point d'entrée, les protections d'un projet Laravel : ce que le framework fait déjà par défaut, ce que le portfolio applique réellement (chaque fois signalé) et ce qui reste à ajouter. Les exemples sont écrits pour Laravel 10 ; les écarts avec Laravel 11 et plus sont signalés là où ils existent. Les exemples génériques utilisent un modèle Post (table posts), comme l'article sur l'API REST, et celui sur les tests décrit la suite du portfolio.

CSRF : la protection qu'on oublie de vérifier parce qu'elle est « automatique »

Laravel gère le CSRF sans configuration. Un token est généré pour chaque session et vérifié à chaque requête POST, PUT, PATCH, DELETE. En pratique :

<form method="POST" action="/profile">
    @csrf
    <!-- Formulaire -->
</form>

Sans @csrf, le formulaire renvoie une erreur 419. C'est le genre de protection qu'on ne remarque pas tant qu'elle marche. Là où ça se complique, c'est avec les requêtes AJAX. Cas fréquent : un formulaire envoyé en fetch sans le token CSRF dans les en-têtes. Les tests automatisés passent, parce que Laravel ignore la vérification CSRF quand l'application tourne en tests, mais dans le navigateur la requête est rejetée avec une 419 et le formulaire ne part jamais. Le token doit donc être transmis :

<meta name="csrf-token" content="{{ csrf_token() }}">
fetch('/contact', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
    },
    body: JSON.stringify(data),
});

Pour les API, la logique dépend du mode d'authentification. Avec des tokens d'API (Laravel Sanctum en mode « API tokens »), l'authentification passe par le header Authorization et le CSRF n'a pas de sens : il n'y a pas de cookie de session à détourner. En revanche, Sanctum en mode SPA (front et API sur le même domaine principal) s'appuie sur les cookies de session et sur la protection CSRF : le front doit d'abord appeler /sanctum/csrf-cookie, puis envoyer le token avec chaque requête. Sanctum n'est donc « stateless » que dans son mode tokens.

Validation : ne jamais faire confiance au frontend

C'est probablement la règle la plus importante. Même avec une validation JavaScript côté client, les données doivent être revalidées côté serveur : n'importe qui peut ouvrir les DevTools, ou envoyer une requête sans passer par le formulaire.

Voici une validation de formulaire d'inscription. Dans un projet réel, elle vit plutôt dans un Form Request (StoreUserRequest, voir l'article sur l'API) ; elle reste ici dans le contrôleur pour garder l'exemple court :

public function store(Request $request)
{
    $validated = $request->validate([
        'email' => ['required', 'email', 'unique:users', 'max:255'],
        'password' => ['required', 'min:8', 'confirmed'],
        'name' => ['required', 'string', 'max:100', "regex:/^[\p{L}\p{M}\s'’-]+$/u"],
    ]);

    // Utiliser $validated, pas $request->all()
    $user = User::create([
        'name' => $validated['name'],
        'email' => $validated['email'],
        'password' => Hash::make($validated['password']),
    ]);

    return redirect()->route('dashboard');
}

On utilise $validated et non $request->all(). La différence : $request->all() contient tout ce que l'utilisateur a envoyé, y compris des champs inattendus. Avec un User::create($request->all()), si is_admin est assignable en masse (présent dans $fillable, ou $guarded vide), quelqu'un peut s'auto-promouvoir administrateur en ajoutant un champ à la requête. C'est le Mass Assignment, un bug courant. Dans un essai sur Laravel 10.50, un modèle avec $guarded = [] a conservé is_admin, alors qu'un modèle limité à $fillable = ['name', 'email'] l'a ignoré.

La vraie défense contre le Mass Assignment, c'est $fillable (ou $guarded) sur le modèle : seuls les attributs listés peuvent être assignés en masse, et un champ sensible comme is_admin n'y figure jamais. $validated protège seulement si l'on ne passe que les champs validés, et seulement les champs prévus dans $fillable. Les deux se complètent.

Sur le regex du nom : [\p{L}\p{M}\s'’-]+ accepte les lettres Unicode (accents, caractères arabes, etc.), les espaces, les apostrophes et les traits d'union, ce qui évite de refuser « O'Brien » ou « Jean-Pierre ». Dans l'essai, « O'Brien », « Jean-Pierre », « Ève Martin » et « عبد الله » passent, « <script>alert(1)</script> » est refusé. C'est un contrôle de format, pas une protection contre le XSS. La protection contre le XSS, c'est l'échappement à l'affichage (voir plus bas), qui doit s'appliquer quelles que soient les données stockées. Un filtre par regex à l'entrée est facile à contourner et donne une fausse impression de sécurité. Beaucoup de projets se passent même de regex sur les noms, pour ne refuser personne à tort.

Injection SQL : Eloquent protège (sauf quand on le contourne)

Eloquent et le query builder utilisent des requêtes préparées par défaut : les valeurs passées aux méthodes sont liées comme paramètres, pas concaténées dans la requête.

// Sécurisé : Eloquent lie la valeur comme paramètre
User::where('email', $request->email)->first();

// Sécurisé : paramètres bindés
DB::select('SELECT * FROM users WHERE email = ?', [$email]);

// Dangereux : interpolation directe, injection possible
DB::select("SELECT * FROM users WHERE email = '$email'");

La troisième syntaxe est exactement ce que les scripts qui scannent les sites espèrent trouver. Si $email contient ' OR 1=1 --, la requête devient SELECT * FROM users WHERE email = '' OR 1=1 --' et retourne tous les utilisateurs.

Dans l'essai sur Laravel 10.50, la version interpolée avec ' OR 1=1 -- a renvoyé les 3 lignes de la table, la version à paramètre lié aucune. Le piège classique est DB::raw() et les méthodes whereRaw() / selectRaw() : leur contenu est inséré tel quel dans la requête, et les valeurs doivent passer par des bindings en second argument. Cas typique : un whereRaw("status = '$status'") dans un scope, à corriger en whereRaw('status = ?', [$status]). Même vigilance pour les noms de colonnes ou les directions de tri venant de l'utilisateur : les bindings ne les protègent pas, il faut les comparer à une liste blanche.

XSS : Blade aide, mais {!! !!} est un piège

Blade échappe automatiquement toutes les variables affichées avec {{ }} :

{{-- Sécurisé : les balises HTML sont échappées --}}
{{ $user->name }}

{{-- Non échappé : le HTML est rendu tel quel --}}
{!! $trustedHtml !!}

Afficher du Markdown rendu en HTML oblige à utiliser {!! !!}. Or Str::markdown() laisse passer par défaut une partie du HTML brut. Dans l'essai (Laravel 10.50 et 12.69, même résultat), la balise <script> est échappée par l'extension GitHub-flavored de CommonMark, mais <img src=x onerror=alert(1)> est restitué tel quel, et un lien [lien](javascript:alert(1)) produit un href="javascript:alert(1)". Dire « le contenu vient de l'admin » est une défense faible : un compte admin compromis, un contenu copié depuis une source externe ou une future fonctionnalité de contribution suffisent à rendre le risque réel. Il faut neutraliser le HTML à la conversion :

{!! Str::markdown($article->content, [
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]) !!}

html_input => 'strip' supprime le HTML brut et allow_unsafe_links => false retire l'attribut href des liens javascript:. Avec ces deux options, l'essai ne laisse plus passer ni onerror ni javascript:. Pour les commentaires ou les messages de contact, jamais de {!! !!}.

Lorsqu'il faut accepter du HTML saisi par un utilisateur (un éditeur riche par exemple), HTMLPurifier est la solution :

use HTMLPurifier;

$clean = (new HTMLPurifier)->purify($userInput);

Sur le portfolio, un helper HtmlPurifier encapsule cette logique, et le rendu des articles (ArticleContentHelper) fait passer le résultat de Str::markdown() par HtmlPurifier::clean() avant l'affichage. Il supprime les balises <script>, les attributs onclick, les javascript: dans les URLs, c'est-à-dire tout ce qui pourrait exécuter du code côté client.

Autorisation : middleware d'accès admin, puis Policies

Sur le portfolio, l'autorisation est volontairement simple : toute la zone d'administration est protégée par un middleware maison, IsAdmin, qui vérifie que l'utilisateur authentifié est administrateur. Il n'y a ni Policy ni Gate dans le dépôt. Pour un site avec un seul rôle à privilèges, c'est suffisant, mais cela ne passe pas à l'échelle : dès qu'il faut distinguer qui peut modifier quelle ressource, les vérifications finissent en if ($user->id !== $post->user_id) éparpillés dans les controllers, dupliqués et difficiles à tester.

Les Policies sont l'étape suivante recommandée. Elles centralisent la logique d'autorisation, avec la convention de nom PostPolicy pour le modèle Post :

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }
}

// Dans le contrôleur
public function update(UpdatePostRequest $request, Post $post)
{
    $this->authorize('update', $post);
    // ...
}

$this->authorize() lève une exception (réponse 403) si la policy retourne false. C'est propre, testable unitairement, et cela retire de la logique conditionnelle des controllers. Note de version : authorize() vient du trait AuthorizesRequests, présent dans le contrôleur de base de Laravel 10 ; le contrôleur de base d'un projet neuf en Laravel 11 et plus est vide, et l'on appelle alors Gate::authorize('update', $post). Ajouter des Policies pour les articles, les messages de contact et les réglages serait la prochaine amélioration logique du portfolio.

Le hashage : ce n'est pas optionnel

Ça devrait être évident, mais on croise encore des mots de passe stockés en clair dans des projets d'apprentissage. Laravel utilise bcrypt par défaut :

use Illuminate\Support\Facades\Hash;

$hashed = Hash::make($password);

if (Hash::check($password, $hashed)) {
    // Mot de passe correct
}

Inutile de réinventer le hashage : pas de md5(), pas de sha256() maison. Hash::make() utilise bcrypt avec un salt unique par hash et un coût de calcul réglable qui rend le brute-force coûteux. Pour utiliser Argon2id (plus récent, plus coûteux en mémoire, donc moins favorable aux attaques GPU), on le configure dans config/hashing.php.

Rate limiting : freiner le brute-force et les abus

Un endpoint de login exposé reçoit tôt ou tard des rafales de tentatives. Le rate limiting les freine. Sur le portfolio, la protection repose sur deux mécanismes :

  • le login admin passe par un middleware maison, ThrottleLoginAttempts : 5 tentatives par couple e-mail et IP, et 20 par IP, sur une fenêtre de 15 minutes ;
  • le formulaire de contact est limité par throttle:10,1 (10 envois par minute et par IP), complété par un quota horaire de 3 envois par IP dans ContactSecurityService, et protégé par un champ honeypot qui piège les robots sans gêner les visiteurs.

Pour un projet qui n'a pas besoin d'un middleware sur mesure, Laravel fournit RateLimiter::for, une alternative plus simple. Les limiteurs nommés se déclarent dans RouteServiceProvider::configureRateLimiting() en Laravel 10, et dans le boot() d'un service provider (AppServiceProvider) en Laravel 11 et plus, qui n'a plus de RouteServiceProvider :

// Un rate limiter nommé pour le login
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->ip());
});

// Appliqué aux routes
Route::middleware('throttle:login')->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
});

Cinq tentatives par minute et par IP : au-delà, le visiteur reçoit une erreur 429. C'est strict, mais pour un login admin c'est justifié. Une API publique peut tolérer davantage, selon l'usage. Pour un formulaire de contact, une limite basse (quelques messages par heure et par IP) bloque le spam automatisé sans gêner les visiteurs légitimes.

Derrière un reverse proxy ou un CDN, $request->ip() renvoie l'adresse du proxy tant que les proxies de confiance ne sont pas configurés (TRUSTED_PROXIES). Sans cela, tous les visiteurs partagent la même limite.

Headers de sécurité : les couches invisibles

Ce sont des en-têtes HTTP que le navigateur utilise pour appliquer des restrictions de sécurité. Un middleware dédié suffit. Le portfolio envoie sept en-têtes : X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy et X-XSS-Protection partout, plus Strict-Transport-Security et Content-Security-Policy (avec un nonce par requête) en production. Version simplifiée :

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

        // X-XSS-Protection désactivé : voir plus bas
        $response->headers->set('X-XSS-Protection', '0');

        if (app()->isProduction()) {
            $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
            // Politique à adapter aux CDN et aux scripts inline réellement utilisés
            $response->headers->set('Content-Security-Policy', "default-src 'self'; frame-ancestors 'self'");
        }

        return $response;
    }
}

En bref :

  • nosniff empêche le navigateur de deviner le type MIME d'un fichier.
  • X-Frame-Options: SAMEORIGIN bloque l'intégration du site dans un iframe externe (clickjacking).
  • Referrer-Policy limite les informations envoyées dans l'en-tête Referer.
  • Permissions-Policy désactive les API du navigateur dont le site n'a pas besoin.
  • HSTS force le HTTPS pendant un an. À n'activer qu'une fois le HTTPS en place sur le domaine, et includeSubDomains seulement si tous les sous-domaines le supportent.
  • X-XSS-Protection: 0 désactive explicitement l'ancien filtre des navigateurs (voir ci-dessous).
  • Content-Security-Policy restreint les sources de scripts, styles et images autorisées. C'est le header le plus efficace contre le XSS, et le plus délicat à régler : on commence souvent par Content-Security-Policy-Report-Only pour voir ce qui serait bloqué.

Un seul réglage est à éviter : X-XSS-Protection: 1; mode=block. Il pilotait un filtre XSS intégré aux anciens navigateurs, retiré des navigateurs actuels parce qu'il pouvait lui-même introduire des failles. L'OWASP recommande de ne pas l'envoyer, ou de l'envoyer avec la valeur 0. C'est ce que fait le middleware du portfolio, et un test vérifie que la valeur est bien 0. La défense repose sur la CSP et sur l'échappement en sortie.

Un outil comme securityheaders.com permet de contrôler ces en-têtes et attribue une note.

Les secrets : ce qui ne doit jamais finir dans Git

Erreur fréquente en début de projet : commiter un .env avec les identifiants de la base de données, parce que « c'est un dépôt privé, il n'y a pas de conséquence ». C'est faux : un secret commité reste dans l'historique Git, même si le fichier est supprimé au commit suivant. Un repo privé peut être cloné, forké, rendu public un jour, ou accessible à des outils tiers (CI, intégrations). Dès qu'un secret a été commité, il faut le considérer comme compromis et le renouveler (nouveau mot de passe de base de données, nouvelle clé API, nouvelle APP_KEY si besoin). Réécrire l'historique (avec git filter-repo, par exemple) peut compléter, mais ne remplace pas la rotation.

# .env : dans le .gitignore, jamais dans le repo
APP_KEY=base64:...
DB_PASSWORD=secret
API_SECRET=xxx

Ce qui va dans le repo, c'est un .env.example avec des valeurs factices.

// Dans le code, utiliser config() : jamais env() directement
$secret = config('services.api.secret');

Pourquoi config() et pas env() ? Parce que env() ne fonctionne plus hors des fichiers de configuration quand le cache de configuration est activé (php artisan config:cache), ce qui est recommandé en production. config() lit les valeurs mises en cache. Avec env() dans les classes applicatives, tout marche en développement et casse en production, avec des valeurs null difficiles à relier à leur cause.

À mettre en place dès le premier jour

Sur un nouveau projet Laravel, le rate limiting et les en-têtes de sécurité viennent avant la mise en production, pas après les premières tentatives d'attaque repérées dans les logs. Les Policies s'imposent dès qu'il y a plus d'un rôle utilisateur, plutôt que des if éparpillés dans les controllers. Enfin, composer audit à intervalles réguliers couvre les vulnérabilités des packages tiers, un vecteur qu'on oublie facilement parce qu'on ne voit pas le code (le workflow CI du portfolio l'exécute à chaque exécution, voir l'article sur GitHub Actions). La montée de version fait aussi partie de la sécurité : selon la politique de support officielle de Laravel, la branche 10 ne reçoit plus de correctifs de sécurité depuis février 2025.

En Laravel, le framework fait déjà beaucoup par défaut. Le vrai risque est de contourner ces protections sans s'en rendre compte : un DB::raw() sans binding, un {!! !!} sur du contenu non nettoyé, un $request->all() dans un create(). Ces petites inattentions créent les failles.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.