7min.

Comprendre et éviter les attaques CSRF grâce à Symfony

CSRF veut dire Cross-Site Request Forgery en anglais, une traduction française pourrait être « Falsification de requêtes inter-sites ».

Dans cet article, nous allons faire un rappel de ce qu’est une attaque CSRF et comment Symfony nous en protège. Puis au travers d’un exemple concret, voir qu’il reste des cas qui sortent du cadre classique et méritent toute notre attention.

Section intitulée qu-est-ce-qu-une-attaque-csrfQu’est-ce qu’une attaque CSRF ?

L’objet de cette attaque est de transmettre à un utilisateur authentifié une requête HTTP falsifiée qui pointe sur une action interne au site, afin qu’il l’exécute sans en avoir conscience et en utilisant ses propres droits. L’utilisateur devient donc complice d’une attaque sans même s’en rendre compte. L’attaque étant actionnée par l’utilisateur, un grand nombre de systèmes d’authentification sont contournés. Source : Wikipédia

Concrètement l’attaque se passe en trois temps :

  • Alice est administratrice d’un forum, elle a une session active.
  • Bob forge une URL /admin/delete/comment/345, l’obstrue par exemple avec un raccourcisseur d’URL, puis l’envoie à Alice
  • Alice clique sur l’URL, ayant une session active, l’action destructrice est exécutée malgré elle

Heureusement, Symfony est bien fait et grâce au package symfony/security-csrf (ou via le composant Form qui l’intègre), le framework s’occupe de tout !

Il est cependant intéressant de s’y re-pencher un peu pour éviter les quelques pièges restants.

Section intitulée formulairesFormulaires

Quand vous utilisez un FormType et que l’option CSRF est activée dans la configuration (ce qui est le cas par défaut), vous êtes protégé.

# config/packages/framework.yaml
framework:
    csrf_protection: true

Section intitulée c-est-magiqueC’est magique

En effet, Symfony est assez intelligent pour ajouter un champ caché dans votre formulaire au moment où vous l’utilisez dans votre application.

Form fonctionne parfaitement avec le moteur de template Twig :

{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_end(form) }}
   ^-- L'objet formView est rendu
       et ajoute un champ caché avec le token

Concrètement, c’est ce bout de code qui sera automatiquement ajouté pour vous :

<form>
    ...
    <input type="hidden" id="form_name__token"
        name="form_name[_token]"
        value="...87deab327835c5e20c17964c41b3b854...">
</form>

Ainsi, quand dans votre contrôleur, vous faites votre appel au formulaire et à sa validité, le CSRF est vérifié dans la foulée. Magie !

if ($form->isSubmitted() && $form->isValid()) { ... }

Section intitulée la-magie-a-un-prixLa magie a un prix

Les tokens CSRF utilisés sont différents pour chaque utilisateur et sont stockés dans la session (en plus d’être ajoutés au formulaire donc) afin d’être comparés. Une session est donc automatiquement démarrée dès que vous créez un formulaire avec une protection CSRF.

Cela signifie concrètement que vous ne pouvez plus mettre en cache ces pages protégées contre les attaques CSRF !

Il existe des solutions, comme l’utilisation des caches partiels, ou l’injection du token CSRF en Ajax, mais elles dépassent le cadre de cet article. Plus d’informations en anglais dans la documentation officielle de Symfony.

Note : Il peut arriver que vous deviez gérer un formulaire manuellement ; à ce moment-là, n’oubliez pas qu’il est nécessaire de vous occuper du CSRF vous-même. Nous en verrons un exemple plus loin dans l’article.

Section intitulée quelques-cas-restent-a-votre-chargeQuelques cas restent à votre charge

Pour protéger les actions destructrices dans vos contrôleurs, il est important de bien faire attention à la sécurité. Mais, il est possible que certaines fonctionnalités de Symfony portent à confusion sur l’étendue de la sécurisation qu’elles permettent.

Dans ce scénario, une nouvelle personne développe pour la première fois sur Symfony. Cette personne a compris que Symfony gère automatiquement les problématiques de CSRF et protège donc son contrôleur grâce à un simple IsGranted.

Mais ça n’est pas suffisant !

Section intitulée protege-par-code-isgranted-codeProtégé par IsGranted

#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
	// ...

    #[Route(path: '/admin/comment/{id}/delete', name: 'admin_comment_delete')]
    public function commentDelete(Comment $comment, CommentRepository $commentRepository): Response
    {
        $commentRepository->remove($comment, flush: true);

        return $this->redirectToRoute('admin_comment_list');
    }
}

Mon contrôleur est protégé par un #[IsGranted('ROLE_ADMIN')], c’est donc sécurisé !

Eh bien non, une attaque de type CSRF est possible via un lien piégé envoyé à l’administrateur. Par exemple, l’attaquant a repéré le commentaire avec l’id 345 et il veut le supprimer. Il n’est pas administrateur, mais il peut créer un lien vers https://example.org/admin/comment/345/delete, ce lien il va le cacher derrière un raccourcisseur d’URL par exemple : https://tinyurl.com/funny-cat-playing-piano et maintenant il suffit d’envoyer ce lien à un administrateur et qu’il clique dessus. En cliquant dessus, étant déjà identifié sur le site cible, l’administrateur lancera malgré lui l’action cachée derrière l’URL. C’est un exemple grossier, mais l’idée est là.

Section intitulée protege-par-code-methods-post-codeProtégé par methods: ['POST']

#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
	// ...

    #[Route(
        path: '/admin/comment/{id}/delete',
        name: 'admin_comment_delete',
        methods: ['POST'])
    ]
    public function commentDelete(Comment $comment, CommentRepository $commentRepository): Response
    {
        $commentRepository->remove($comment, flush: true);

        return $this->redirectToRoute('admin_comment_list');
    }
}

couplé à :

<form action="{{ path('admin_comment_delete', {id: comment.id}) }}" method="post">
    <button>Delete Comment</button>
</form>

Mon action est protégée, car l’attaquant ne peut pas forger un lien, c’est désormais un formulaire et la méthode doit être un POST.

Eh bien, encore non, une attaque de type CSRF est toujours possible, bien que plus compliquée. L’attaquant peut créer un formulaire POST qui pointe sur l’URL https://example.org/admin/comment/345/delete. Avec un peu de JavaScript ce formulaire peut être auto-soumis au chargement de la page :

<form action="https://example.org/admin/comment/345/delete" method="POST"></form>
<script>document.forms[0].submit()</script>

Une fois la page hébergée, il suffit d’envoyer le lien vers cette page, ou un lien caché. Le problème reste le même.

Section intitulée protege-par-un-token-csrfProtégé par un token CSRF ✅

#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
	// ...

    #[Route(
        path: '/admin/comment/{id}/delete',
        name: 'admin_comment_delete',
        methods: ['POST'])
    ]
    public function commentDelete(Request $request, Comment $comment, CommentRepository $commentRepository): Response
    {
        if (
            !$this->isCsrfTokenValid(
                'delete' . $comment->getId(),
                $request->request->get('_token')
        )) {
            throw new BadRequestHttpException();
        }

        $commentRepository->remove($comment, flush: true);

        return $this->redirectToRoute('admin_comment_list');
    }
}

couplé à :

<form action="{{ url('admin_comment_delete', {id: comment.id}) }}" method="post">
    <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ comment.id) }}"/>
    <button>Delete Comment</button>
</form>

Détail sur la méthode : isCsrfTokenValid(string $id, ?string $token): bool

Le but du paramètre $id est de créer un token le plus unique possible, vous pouvez le voir comme un namespace. Dans notre exemple, nous avons créé un token basé sur un mot “delete” concaténé à l’id de l’objet que nous souhaitons supprimer. Ces deux informations étant disponibles dans le template et dans notre action, il est donc possible d’en faire un namespace et de sécuriser encore plus l’ensemble. Le second paramètre $token est tout simplement le token envoyé par le formulaire dans la requête. La méthode s’occupe ensuite de valider l’ensemble.

Cette fois-ci c’est un succès, l’action est protégée des attaques CSRF. Symfony s’occupe de la génération et validation du token, qui change à chaque requête. L’attaquant ne peut pas générer un token valide.

Section intitulée aller-plus-loin-dans-la-securiteAller plus loin dans la sécurité

Il existe une variante de l’attaque CSRF qui vise spécifiquement les formulaires de login. L’idée n’est pas ici de forcer la personne à réaliser une action avec ses droits, mais à l’inverse de la connecter avec les identifiants de l’attaquant afin de lui faire réaliser des actions pour le compte de l’attaquant : exemple, payer une commande. Symfony dispose d’une configuration spécifique pour se prémunir de ces attaques : documentation officielle > Sécurité > Form login

Évidemment, la protection CSRF n’est qu’un seul des nombreux aspects de sécurité autour des formulaires et plus généralement des applications Web.

Par exemple Laurent Brunet nous parle du CSP (Content Security Policy) mais on pourrait aussi citer CORS qui est complémentaire.

Section intitulée conclusionConclusion

Sécuriser une application est souvent compliqué, mais heureusement Symfony nous donne accès à beaucoup d’outils. Cela n’empêche pas un oubli de la part de la personne qui implémente.

Bien sûr, il existe des aide-mémoire récapitulant tous les points de vigilance. Une de ces listes peut être retrouvée sur Open Worldwide Application Security Project.

C’est sans doute une bonne pratique de vérifier l’ensemble des points avant de livrer un projet !

Commentaires et discussions

Nos articles sur le même sujet

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise