Aller au contenu

Avant de mettre en ligne : APP_DEBUG, .env, jeton CSRF

Temps 2 · séance 2 · ~20 min

Les slides du chapitre (PDF)

  • Comparer ce qu'une erreur montre avec APP_DEBUG=true et avec APP_DEBUG=false.
  • Vérifier que .env reste hors de Git et hors du dossier servi.
  • Lancer composer audit et npm audit, et dire ce qu'ils vérifient.
  • Expliquer comment Laravel arrête une requête forgée depuis un autre site, et provoquer un 419.
  • Passer la liste de contrôle sur votre portfolio en ligne.

1. Le mode débogage, laissé allumé

En novembre 2020, des chercheurs de la société Lexfo trouvent une faille dans Ignition, alors la page d'erreur détaillée de Laravel. Enregistrée sous le nom CVE-2021-3129, elle laisse un visiteur exécuter du code sur un site en mode débogage, jusqu'à la version 2.5.1 d'Ignition. En janvier 2024, l'agence américaine de cybersécurité, la CISA, et le FBI décrivent Androxgh0st, un réseau de machines infectées qui cherche les sites Laravel dont le fichier .env est lisible ou qui tournent en mode débogage, pour y prendre des identifiants de messagerie et de cloud. Leur consigne : pas de mode débogage en production, et retirer puis révoquer les identifiants de .env.

La faille d'Ignition venait de son code, et le mode débogage permettait de l'atteindre. Laravel 13 n'installe plus Ignition : sa page de débogage fait partie du framework. Dans le Tamagotchi, il n'y a pas de faille à corriger ici, mais des réglages et des paquets à vérifier avant chaque mise en ligne, rassemblés dans la liste de contrôle de la section 7.

2. APP_DEBUG : ce que voit le visiteur

Au chapitre 2.06, l'apostrophe de L'os a ouvert la page de débogage de Laravel, avec la requête SQL entière. Elle s'est affichée parce que le .env de votre copie contient APP_DEBUG=true, comme celui du kit.

Ajoutez une route provisoire qui lève une exception, à la fin de routes/web.php :

Route::get('demo-erreur', function () {
    \App\Models\User::count();

    throw new RuntimeException('Erreur provoquée pour la démonstration.');
});

Connecté comme Alice, ouvrez tamagotchi.test/demo-erreur. Le serveur répond 500, et la page de débogage commence ainsi :

Le haut de la page de débogage de Laravel : RuntimeException, le fichier routes/web.php ligne 39, le message Erreur provoquée pour la démonstration., puis les versions Laravel 13.42.0 et PHP 8.4.12

Plus bas, la même page donne ceci, sans rien masquer. Les valeurs longues sont coupées ici :

Exception trace
  Illuminate\Routing\RouteFileRegistrar->{closure:C:\…\routes\web.php:36}()
  36 Route::get('demo-erreur', function () {
  37     \App\Models\User::count();
  38
  39     throw new RuntimeException('Erreur provoquée pour la démonstration.');
Queries
  select * from "sessions" where "id" = '2Ijr…' limit 1
  select * from "users" where "id" = 1 limit 1
  select count(*) as "aggregate" from "users"
Headers
  USER-AGENT  Mozilla/5.0 (Windows NT 10.0; Win64; x64) …
  COOKIE      XSRF-TOKEN=eyJpdiI6…; tamagotchi-session=eyJpdiI6…
Routing
  CONTROLLER  Closure
  MIDDLEWARE  web

Un visiteur y lit le dossier du projet sur le serveur, le code source, les versions, chaque requête SQL avec ses valeurs, dont l'identifiant de la session, et les cookies en entier. La page de Laravel 13 ne montre pas le contenu de .env, mais elle en dit assez pour préparer une attaque.

Mettez maintenant APP_DEBUG=false dans .env, puis rechargez la même adresse :

La page d'erreur de production : 500 et Erreur serveur, rien d'autre

Le détail n'est pas perdu : Laravel l'écrit dans storage/logs/laravel.log, comme au chapitre 1.22. Remettez APP_DEBUG=true et retirez la route provisoire.

3. .env : hors de Git, hors du dossier servi

.env contient les secrets de l'application : APP_KEY, la clé avec laquelle Laravel chiffre les cookies, celui de la session compris, et en ligne les accès à la base ou au serveur d'e-mail. Il ne doit sortir ni par Git, ni par le serveur web.

Hors de Git

Le fichier .gitignore du kit l'écarte, ligne 15. git check-ignore -v le confirme :

git check-ignore -v .env
.gitignore:15:.env	.env

Une règle de .gitignore n'agit que sur les fichiers que Git ne suit pas encore. Un .env déjà ajouté par erreur reste suivi, et ses anciennes versions restent dans l'historique. git ls-files, qui liste les fichiers suivis, le dirait :

git ls-files | grep env
.env.example

Dans PowerShell, remplacez grep env par Select-String env. Un secret qui a déjà été poussé reste publié, même après le retrait du fichier : il se révoque, c'est-à-dire qu'on le remplace par un nouveau chez le service qui l'a donné.

.env.example, lui, est versionné : c'est le modèle copié au chapitre 2.01 avec cp .env.example .env. Il porte les noms des variables, avec des valeurs sans danger : APP_KEY= vide, DB_PASSWORD= en commentaire, MAIL_MAILER=log.

Votre copie a déjà sa clé, générée au chapitre 2.01 par php artisan key:generate. Ne relancez pas cette commande pour voir : elle remplacerait la clé, déconnecterait chaque session et rendrait illisible ce qui a été chiffré avec l'ancienne. L'option --show affiche une clé neuve sans toucher à .env :

php artisan key:generate --show
base64:…

Une clé publiée par erreur se remplace quand même, en acceptant ces conséquences, avec les mots de passe qui étaient à côté d'elle.

Hors du dossier servi

Au chapitre 1.22, vous avez réglé le dossier racine de votre domaine sur public chez OVH. Le serveur web n'envoie que ce qui est dans ce dossier. .env et database/database.sqlite sont un niveau plus haut, hors de portée. Vérifiez ce réglage dans l'interface d'OVH, puis demandez l'adresse /.env. Avec public comme racine, elle n'est qu'une route inconnue de Laravel. L'option -i de curl affiche les en-têtes de la réponse, dont la première ligne porte le statut :

curl -s -i http://tamagotchi.test/.env
HTTP/1.… 404 Not Found
…

Dans PowerShell, écrivez curl.exe au lieu de curl. Si le domaine servait le dossier du projet entier, .env pourrait devenir lisible, selon les règles du serveur web. C'est la requête GET /.env qu'Androxgh0st envoie à chaque site Laravel qu'il trouve.

Les réglages de production

Le .env du serveur change ces lignes, comme au chapitre 1.22 :

APP_ENV=production
APP_DEBUG=false
APP_URL=https://votre-adresse.be

APP_ENV=production change aussi le comportement du kit. Dans app/Providers/AppServiceProvider.php, app()->isProduction() interdit alors les commandes qui effacent la base, comme migrate:fresh, et exige des mots de passe plus solides, de douze caractères au moins. Si la configuration a été mise en cache par php artisan config:cache, Laravel ne relit plus .env : relancez cette commande après chaque changement.

4. composer audit et npm audit

Le Tamagotchi installe 175 paquets PHP dans vendor/, et package-lock.json liste 318 paquets JavaScript. Une faille trouvée dans l'un d'eux est publiée dans une base d'avis de sécurité. composer audit compare les versions installées à cette base :

composer audit
No security vulnerability advisories found.
Found 2 abandoned packages:
+-----------------------+-----------------------+
| Abandoned Package     | Suggested Replacement |
+-----------------------+-----------------------+
| dragon-code/contracts | none                  |
| dragon-code/support   | illuminate/support    |
+-----------------------+-----------------------+

Aucun avis de sécurité. Les deux paquets « abandonnés », plus entretenus par leur auteur, viennent de laravel-lang, les traductions françaises, installées pour le développement seulement. composer audit --no-dev ne regarde que les paquets qui partent en production, et répond No security vulnerability advisories found. sans autre remarque.

npm audit fait la même chose pour package-lock.json :

npm audit
found 0 vulnerabilities

Un paquet sain ce soir peut recevoir un avis demain : relancez les deux commandes avant chaque mise en ligne. Quand un avis tombe, lisez-le et cherchez la version corrigée compatible avec votre projet. Mettez le paquet à jour, puis relancez les audits, les tests et npm run build. Certaines corrections demandent de changer une contrainte de version à la main.

5. Le jeton CSRF et le 419

Une requête forgée

Alice est connectée au Tamagotchi. Dans un autre onglet, elle ouvre une page d'un tout autre site. Cette page contient un formulaire caché qui vise le Tamagotchi, et un script qui l'envoie dès le chargement :

<form method="POST" action="http://tamagotchi.test/enclos">
    <input type="hidden" name="_method" value="PATCH">
    <input type="hidden" name="motto" value="Mallory était là">
</form>
<script>document.forms[0].submit();</script>

Une requête envoyée depuis un autre site au nom d'une personne connectée s'appelle une requête forgée, en anglais cross-site request forgery, d'où le sigle CSRF.

Deux vérifications, dans cet ordre

Toute route de routes/web.php passe par le middleware PreventRequestForgery. Sa méthode handle(), dans vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestForgery.php, laisse passer une requête dès qu'une de ces conditions est vraie :

if (
    $this->isReading($request) ||
    $this->runningUnitTests() ||
    $this->inExceptArray($request) ||
    $this->hasValidOrigin($request) ||
    $this->tokensMatch($request)
) {
    return tap($next($request), /* … */);
}

throw new TokenMismatchException('CSRF token mismatch.');

Les trois premières laissent passer une lecture (GET), les tests Pest et les adresses exclues. Pour une requête qui écrit, les deux dernières décident, dans cet ordre.

  1. L'origine. Une origine réunit le protocole, le nom d'hôte et le port : http, tamagotchi.test, et le port par défaut. hasValidOrigin() lit l'en-tête Sec-Fetch-Site, que le navigateur ajoute lui-même et qu'une page ne peut pas changer. S'il vaut same-origin, la page qui envoie a la même origine que l'adresse visée, et la requête passe.
  2. Le jeton. Sinon, tokensMatch() compare le jeton CSRF de la session, une valeur tirée au hasard pour chaque session, avec celui de la requête. Le jeton arrive dans le champ _token, ou dans l'en-tête X-CSRF-TOKEN ou X-XSRF-TOKEN.

Laquelle des deux joue dépend de l'adresse. Chrome n'envoie Sec-Fetch-Site qu'aux adresses qu'il juge sûres : celles en https, et localhost ou 127.0.0.1. Relevé dans la console d'une page du magasin, le même fetch en POST :

Adresse ouverte Sec-Fetch-Site envoyé Sans jeton Avec X-XSRF-TOKEN
http://127.0.0.1 same-origin accepté accepté
http://tamagotchi.test absent 419 accepté

Sur votre copie, en http sur un nom en .test, le jeton décide toujours. C'est pour cela que les fetch des chapitres 2.02, 2.05 et 2.10 lisent le jeton avant d'envoyer leur requête. Sur votre portfolio en https, l'origine décide d'abord, et le jeton sert de secours.

Le jeton, envoyé par <Form>

Un autre site ne peut pas lire le jeton, et votre <Form> d'Inertia l'envoie sans que vous l'écriviez. Laravel pose à chaque réponse un cookie XSRF-TOKEN qui contient le jeton chiffré, sans l'attribut HttpOnly, pour que le JavaScript de la page puisse le lire. Avant chaque requête, Inertia lit ce cookie et recopie sa valeur dans l'en-tête X-XSRF-TOKEN. Voici la requête du formulaire de code cadeau, relevée à l'envoi :

POST /codes-cadeau
content-type: application/json
x-inertia: true
x-xsrf-token: eyJpdiI6…
{"code":"0000"}

Le 419

La requête forgée d'Alice, rejouée sur tamagotchi.test, part avec l'en-tête origin: http://site-piege.test, sans Sec-Fetch-Site, et sans aucun jeton. Laravel lève TokenMismatchException et répond par le statut 419 :

La page d'erreur de Laravel : 419 et Page expirée

La devise d'Alice n'a pas changé. curl, qui n'envoie ni origine ni jeton, provoque le même 419 :

curl -s -i -X POST -d code=0000 http://tamagotchi.test/codes-cadeau
HTTP/1.1 419 unknown status
…

Une requête forgée depuis un autre site. Alice, connectée, ouvre une page piégée qui envoie seule un formulaire POST vers /enclos du Tamagotchi. PreventRequestForgery demande d'abord si la requête vient de la même origine, d'après Sec-Fetch-Site : non. Puis si elle porte le jeton de la session : non. Réponse 419, la devise d'Alice ne change pas. Un formulaire du Tamagotchi passe par l'origine en https, et par l'en-tête X-XSRF-TOKEN sinon

Une seconde protection s'ajoute : le cookie de session est marqué SameSite=lax, et Chrome ne l'a pas joint à la requête forgée, partie sans aucun cookie.

Le jeton a une limite, que le chapitre 2.05 a montrée : un script qui tourne déjà sur une page du site lit le cookie XSRF-TOKEN comme le fait Inertia. Le jeton CSRF ne peut rien contre une faille XSS.

6. Deux lignes de plus : les fichiers et les cookies

Les fichiers téléversés. Au chapitre 1.14, la règle ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'] a refusé un PDF et un fichier trop lourd, et store('projects', 'public') a rangé l'image sous un nom tiré au hasard. Le serveur n'accepte que les formats prévus et les fichiers de 2 Mo au plus, et c'est store() qui choisit le nom du fichier enregistré.

Les cookies de session. config/session.php règle trois attributs du cookie :

'secure' => env('SESSION_SECURE_COOKIE'),
'http_only' => env('SESSION_HTTP_ONLY', true),
'same_site' => env('SESSION_SAME_SITE', 'lax'),

La réponse de /login les montre, valeurs coupées :

Set-Cookie: XSRF-TOKEN=…; expires=…; Max-Age=7200; path=/; samesite=lax
Set-Cookie: tamagotchi-session=…; expires=…; Max-Age=7200; path=/; httponly; samesite=lax

httponly interdit au JavaScript de lire le cookie de session, et samesite=lax le retient sur les formulaires POST venus d'un autre site. secure manque : il ne laisserait partir le cookie qu'en https, et SESSION_SECURE_COOKIE n'est pas dans .env. En ligne, ajoutez SESSION_SECURE_COOKIE=true.

7. La liste de contrôle

Pendant la séance, vérifiez sur votre portfolio en ligne APP_DEBUG, la racine public et les attributs du cookie de session. Terminez la liste après la séance, puis passez-la sur le projet en binôme avant sa mise en ligne. Chaque ligne se vérifie par une commande ou un regard.

La liste de contrôle avant la mise en ligne : neuf réglages de configuration, de APP_DEBUG au téléversement, puis une règle de code par faille, de la policy du chapitre 2.02 au throttle du chapitre 2.10

La configuration

  • Le .env du serveur contient APP_ENV=production et APP_DEBUG=false, et une erreur affiche bien « 500 Erreur serveur », sans détail. Si la configuration est en cache, php artisan config:cache a été relancé après le changement.
  • APP_URL est l'adresse publique en https://.
  • APP_KEY est remplie, et n'apparaît dans aucun fichier versionné ni aucune capture.
  • git ls-files | grep env ne répond que .env.example, ce fichier ne contient aucun mot de passe, et aucun secret n'a été poussé dans l'historique.
  • Le domaine sert le dossier public, d'après l'interface d'OVH, et https://votre-adresse.be/.env répond 404.
  • composer audit et npm audit ne signalent aucune vulnérabilité.
  • Chaque formulaire passe par <Form>, et bootstrap/app.php n'exclut aucune route du jeton CSRF.
  • Le cookie de session est httponly, samesite=lax, et secure grâce à SESSION_SECURE_COOKIE=true.
  • Chaque champ de fichier est validé par image, mimes et max, et rangé par store().

Le code, une règle par faille

  • 2.02 : chaque action sur une donnée appelle Gate::authorize() et sa policy.
  • 2.03 : les contrôleurs passent $request->validated(), et #[Fillable] ne liste que des champs de formulaire.
  • 2.04 : une prop qui expose un modèle n'en transmet que les champs choisis, jamais le modèle entier.
  • 2.05 : le contenu écrit par un utilisateur s'affiche avec {{ }}, et le HTML voulu est nettoyé côté serveur.
  • 2.06 : aucune requête SQL ne colle une saisie, chaque valeur passe par un paramètre lié.
  • 2.07 : les montants se comptent en centimes entiers.
  • 2.08 : le prix vient de la base, et la quantité est validée avec des bornes.
  • 2.09 : vérifier le solde puis le débiter se fait dans une transaction, avec un verrou.
  • 2.10 : toute route qui vérifie un secret ou envoie un message porte un throttle.

À retenir

  • APP_DEBUG=true montre au visiteur le code, les requêtes SQL et les cookies, donc un site en ligne tourne avec APP_DEBUG=false.
  • .gitignore écarte un .env que Git ne suit pas encore, et le domaine qui sert public le met hors de portée du Web.
  • .env.example porte les noms des variables, jamais un secret, et un secret déjà poussé se révoque.
  • composer audit et npm audit comparent les paquets installés aux avis de sécurité publiés, et se relancent avant chaque mise en ligne.
  • Laravel accepte une requête qui écrit si Sec-Fetch-Site vaut same-origin ou si elle porte le jeton de la session. Sur http://tamagotchi.test, l'en-tête manque, et le jeton est nécessaire.

Pour aller plus loin