Sécuriser votre application Symfony contre les attaques CSRF et XSS : Guide complet 2024

## Introduction La sécurité des applications web est devenue un enjeu majeur en 2024. Selon l'OWASP, les attaques Cross-Site Scripting (XSS) et Cross-Site Requ

Symfony

Sécuriser votre application Symfony contre les attaques CSRF et XSS : Guide complet 2024

15/01/2024

## Introduction La sécurité des applications web est devenue un enjeu majeur en 2024. Selon l'OWASP, les attaques Cross-Site Scripting (XSS) et Cross-Site Request Forgery (CSRF) figurent parmi les vulnérabilités les plus exploitées. Symfony, en tant que framework PHP moderne, intègre des mécanismes de protection robustes, mais leur configuration appropriée reste essentielle. Dans cet article, nous allons explorer en profondeur comment protéger efficacement votre application Symfony contre ces menaces. ## Comprendre les menaces ### Attaque CSRF (Cross-Site Request Forgery) Une attaque CSRF exploite la confiance qu'un site web accorde à l'utilisateur authentifié. L'attaquant force la victime à exécuter des actions non désirées sur une application web où elle est authentifiée. **Exemple de scénario d'attaque :** ```html ``` Si l'utilisateur est connecté à son compte bancaire, cette requête s'exécutera avec ses cookies de session. ### Attaque XSS (Cross-Site Scripting) Le XSS permet à un attaquant d'injecter du code JavaScript malveillant dans une page web vue par d'autres utilisateurs. **Types de XSS :** - **Reflected XSS** : Le script malveillant provient de la requête HTTP actuelle - **Stored XSS** : Le script est stocké dans la base de données - **DOM-based XSS** : La vulnérabilité existe dans le code JavaScript côté client ## Protection CSRF dans Symfony ### 1. Configuration de base Symfony active la protection CSRF par défaut pour les formulaires. Voici comment la configurer : ```yaml # config/packages/framework.yaml framework: csrf_protection: ~ # ou avec des options personnalisées csrf_protection: enabled: true ``` ### 2. Utilisation dans les formulaires Symfony génère automatiquement un token CSRF pour chaque formulaire : ```php // src/Controller/ProductController.php namespace App\Controller; use App\Entity\Product; use App\Form\ProductType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class ProductController extends AbstractController { #[Route('/product/new', name: 'product_new')] public function new(Request $request): Response { $product = new Product(); $form = $this->createForm(ProductType::class, $product); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // Le token CSRF est automatiquement validé ici $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($product); $entityManager->flush(); return $this->redirectToRoute('product_success'); } return $this->render('product/new.html.twig', [ 'form' => $form->createView(), ]); } } ``` Dans le template Twig : ```twig {# templates/product/new.html.twig #} {{ form_start(form) }} {# Le token CSRF est automatiquement inclus #} {{ form_row(form.name) }} {{ form_row(form.price) }} Créer le produit {{ form_end(form) }} ``` ### 3. Protection CSRF pour les requêtes AJAX Pour les appels AJAX, vous devez gérer manuellement les tokens CSRF : ```php // src/Controller/ApiController.php use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Csrf\CsrfToken; class ApiController extends AbstractController { #[Route('/api/delete/{id}', name: 'api_delete', methods: ['DELETE'])] public function delete( int $id, Request $request, CsrfTokenManagerInterface $csrfTokenManager ): Response { $token = $request->headers->get('X-CSRF-TOKEN'); if (!$csrfTokenManager->isTokenValid(new CsrfToken('delete-item', $token))) { return $this->json(['error' => 'Invalid CSRF token'], 403); } // Logique de suppression return $this->json(['success' => true]); } #[Route('/api/csrf-token', name: 'api_csrf_token')] public function getCsrfToken(CsrfTokenManagerInterface $csrfTokenManager): Response { $token = $csrfTokenManager->getToken('delete-item')->getValue(); return $this->json(['token' => $token]); } } ``` Côté JavaScript : ```javascript // public/js/app.js class ApiClient { constructor() { this.csrfToken = null; this.initCsrfToken(); } async initCsrfToken() { const resp fetch('/api/csrf-token'); const data = await response.json(); this.csrfToken = data.token; } async deleteItem(id) { const resp fetch(`/api/delete/${id}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': this.csrfToken, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Erreur lors de la suppression'); } return await response.json(); } } const apiClient = new ApiClient(); ``` ### 4. Configuration avancée du CSRF ```php // src/Form/ProductType.php use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class ProductType extends AbstractType { public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Product::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'product_item', ]); } } ``` ## Protection XSS dans Symfony ### 1. Échappement automatique avec Twig Twig échappe automatiquement toutes les variables par défaut : ```twig {# templates/blog/post.html.twig #} {# Échappement automatique - SÉCURISÉ #}

{{ post.title }}

{{ post.content }}

{# Le code HTML est échappé automatiquement #} {{ userComment }} {# Si userComment = "" #} {# Sera affiché comme : <script>alert('XSS')</script> #} ``` ### 2. Filtres d'échappement personnalisés ```twig {# Échappement HTML (par défaut) #} {{ user.bio|e('html') }} {# Échappement JavaScript #} {# Échappement CSS #} .user-color { color: {{ user.favoriteColor|e('css') }}; } {# Échappement URL #} Site web {# Échappement d'attributs HTML #} ``` ### 3. Gestion du contenu HTML sécurisé Si vous devez afficher du HTML, utilisez un sanitizer : ```bash composer require tgalopin/html-sanitizer ``` ```php // src/Service/ContentSanitizer.php namespace App\Service; use HtmlSanitizer\SanitizerInterface; class ContentSanitizer { public function __construct( private SanitizerInterface $sanitizer ) {} public function sanitize(string $content): string { return $this->sanitizer->sanitize($content); } } ``` Configuration du sanitizer : ```yaml # config/packages/html_sanitizer.yaml framework: html_sanitizer: sanitizers: app.sanitizer: allowed_elements: - p - a - strong - em - ul - ol - li allowed_attributes: a: ['href', 'title'] allowed_hosts: - example.com allow_relative_links: true ``` Utilisation dans le contrôleur : ```php // src/Controller/BlogController.php use App\Service\ContentSanitizer; class BlogController extends AbstractController { #[Route('/blog/post/create', name: 'blog_create')] public function create( Request $request, ContentSanitizer $sanitizer ): Response { $c>request->get('content'); $sanitizedC>sanitize($content); $post = new BlogPost(); $post->setContent($sanitizedContent); // Sauvegarde en base de données return $this->redirectToRoute('blog_show', ['id' => $post->getId()]); } } ``` Dans Twig, marquez le contenu comme sûr : ```twig {# templates/blog/show.html.twig #} {{ post.content|raw }} {# Utilisez |raw UNIQUEMENT sur du contenu sanitizé ! #} ``` ### 4. Protection contre le XSS dans les attributs JSON ```php // src/Twig/JsonEncodeExtension.php namespace App\Twig; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; class JsonEncodeExtension extends AbstractExtension { public function getFilters(): array { return [ new TwigFilter('json_encode_safe', [$this, 'jsonEncodeSafe']), ]; } public function jsonEncodeSafe($value): string { return json_encode( $value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT ); } } ``` Utilisation : ```twig ``` ## Content Security Policy (CSP) La CSP est une couche de sécurité supplémentaire contre le XSS : ```php // src/EventListener/CspListener.php namespace App\EventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; #[AsEventListener(event: KernelEvents::RESPONSE)] class CspListener { public function __invoke(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } $resp>getResponse(); $csp = implode('; ', [ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://cdn.example.com", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'" ]); $response->headers->set('Content-Security-Policy', $csp); } } ``` Enregistrement du listener : ```yaml # config/services.yaml services: App\EventListener\CspListener: tags: - { name: kernel.event_listener, event: kernel.response } ``` ## En-têtes de sécurité supplémentaires ```php // src/EventListener/SecurityHeadersListener.php namespace App\EventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; #[AsEventListener(event: KernelEvents::RESPONSE)] class SecurityHeadersListener { public function __invoke(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } $resp>getResponse(); // Empêche le navigateur d'interpréter les fichiers comme autre chose que leur type déclaré $response->headers->set('X-Content-Type-Options', 'nosniff'); // Protection contre le clickjacking $response->headers->set('X-Frame-Options', 'DENY'); // Active le filtre XSS du navigateur $response->headers->set('X-XSS-Protection', '1; mode=block'); // Force HTTPS $response->headers->set( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); // Contrôle du référent $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); // Permissions Policy $response->headers->set( 'Permissions-Policy', 'geolocation=(), microph camera=()' ); } } ``` ## Validation et assainissement des entrées ```php // src/Validator/Constraints/NoHtmlTags.php namespace App\Validator\Constraints; use Symfony\Component\Validator\Constraint; #[\Attribute] class NoHtmlTags extends Constraint { public string $message = 'La valeur "{{ value }}" contient des balises HTML non autorisées.'; } ``` ```php // src/Validator/Constraints/NoHtmlTagsValidator.php namespace App\Validator\Constraints; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; class NoHtmlTagsValidator extends ConstraintValidator { public function validate($value, Constraint $constraint): void { if (!$constraint instanceof NoHtmlTags) { throw new UnexpectedTypeException($constraint, NoHtmlTags::class); } if (null === $value || '' === $value) { return; } if ($value !== strip_tags($value)) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $value) ->addViolation(); } } } ``` Utilisation dans une entité : ```php // src/Entity/Comment.php namespace App\Entity; use App\Validator\Constraints as AppAssert; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity] class Comment { #[ORM\Column(type: 'text')] #[Assert\NotBlank] #[Assert\Length(max: 1000)] #[AppAssert\NoHtmlTags] private string $content; // Getters et setters } ``` ## Tests de sécurité ```php // tests/Security/CsrfProtectionTest.php namespace App\Tests\Security; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class CsrfProtectionTest extends WebTestCase { public function testFormSubmissionWithoutCsrfTokenFails(): void { $client = static::createClient(); // Soumettre un formulaire sans token CSRF $client->request('POST', '/product/new', [ 'product' => [ 'name' => 'Test Product', 'price' => 99.99, ] ]); // La soumission doit échouer $this->assertResponseStatusCodeSame(400); } public function testFormSubmissionWithValidCsrfTokenSucceeds(): void { $client = static::createClient(); $crawler = $client->request('GET', '/product/new'); // Extraire le token CSRF du formulaire $form = $crawler->selectButton('Créer le produit')->form(); // Remplir et soumettre le formulaire avec le token $client->submit($form, [ 'product[name]' => 'Test Product', 'product[price]' => 99.99, ]); $this->assertResponseRedirects('/product/success'); } } ``` ```php // tests/Security/XssProtectionTest.php namespace App\Tests\Security; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class XssProtectionTest extends WebTestCase { public function testUserInputIsEscaped(): void { $client = static::createClient(); $xssPayload = ''; $crawler = $client->request('GET', '/comment/create', [ 'content' => $xssPayload ]); // Vérifier que le contenu est échappé $c>getResponse()->getContent(); $this->assertStringNotContainsString('', $content); $this->assertStringContainsString('<script>', $content); } } ``` ## Meilleures pratiques ### 1. Principe de défense en profondeur - **Ne jamais** désactiver la protection CSRF sauf si absolument nécessaire - **Toujours** valider et assainir les entrées utilisateur - **Utiliser** plusieurs couches de protection (validation, échappement, CSP) ### 2. Checklist de sécurité ```yaml # security_checklist.yaml security_audit: csrf_protection: - Vérifier que csrf_protection est activé dans framework.yaml - Tous les formulaires utilisent la protection CSRF - Les endpoints API vérifient les tokens CSRF xss_protection: - Échappement automatique Twig activé - Utilisation de |raw uniquement sur contenu sanitizé - HTML Sanitizer configuré pour le contenu riche - CSP correctement configurée headers: - Content-Security-Policy défini - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Strict-Transport-Security configuré validation: - Validation côté serveur pour toutes les entrées - Contraintes personnalisées pour les cas spécifiques - Tests de sécurité automatisés ``` ### 3. Surveillance et logging ```php // src/EventListener/SecurityEventListener.php namespace App\EventListener; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; #[AsEventListener(event: KernelEvents::EXCEPTION)] class SecurityEventListener { public function __construct( private LoggerInterface $logger ) {} public function __invoke(ExceptionEvent $event): void { $exception = $event->getThrowable(); if ($exception instanceof TokenNotFoundException) { $request = $event->getRequest(); $this->logger->warning('Tentative CSRF détectée', [ 'ip' => $request->getClientIp(), 'uri' => $request->getRequestUri(), 'user_agent' => $request->headers->get('User-Agent'), 'referer' => $request->headers->get('referer') ]); } } } ``` ## Conclusion La sécurisation d'une application Symfony contre les attaques CSRF et XSS nécessite une approche multi-couches : 1. **Protection CSRF** : Utilisez les tokens CSRF pour tous les formulaires et requêtes modifiant des données 2. **Protection XSS** : Profitez de l'échappement automatique de Twig et sanitizez le contenu HTML 3. **En-têtes de sécurité** : Implémentez CSP et autres en-têtes de sécurité 4. **Validation** : Validez et assainissez toutes les entrées utilisateur 5. **Tests** : Automatisez les tests de sécurité 6. **Surveillance** : Loggez les tentatives d'attaques pour analyse Symfony fournit des outils puissants pour sécuriser votre application, mais leur configuration correcte et leur utilisation cohérente sont essentielles. Restez vigilant, maintenez vos dépendances à jour et effectuez des audits de sécurité réguliers. ## Ressources supplémentaires - [Documentation Symfony Security](https://symfony.com/doc/current/security.html) - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [Content Security Policy Guide](https://content-security-policy.com/) - [HTML Sanitizer Bundle](https://symfony.com/doc/current/html_sanitizer.html)