Sécuriser votre application Symfony : Guide complet contre les vulnérabilités OWASP Top 10

# IntroductionLa sécurité des applications web est devenue une priorité absolue dans le développement moderne. Symfony, l'un des frameworks PHP les plus robus

Sécuriser votre application Symfony : Guide complet contre les vulnérabilités OWASP Top 10
Symfony

Sécuriser votre application Symfony : Guide complet contre les vulnérabilités OWASP Top 10

09/03/2026

# Introduction La sécurité des applications web est devenue une priorité absolue dans le développement moderne. Symfony, l'un des frameworks PHP les plus robustes, offre de nombreux outils pour protéger vos applications contre les vulnérabilités courantes référencées par l'OWASP (Open Web Application Security Project). Dans cet article, nous explorerons comment sécuriser efficacement votre application Symfony contre les menaces les plus critiques. ## 1. Protection contre les injections SQL L'injection SQL reste l'une des vulnérabilités les plus dangereuses. Symfony utilise Doctrine ORM qui offre une protection native. ### Bonne pratique avec Doctrine ```php // ❌ MAUVAIS : Vulnérable aux injections SQL $query = $entityManager->createQuery( 'SELECT u FROM App\Entity\User u WHERE u.email = "' . $email . '"' ); // ✅ BON : Utilisation de paramètres préparés $query = $entityManager->createQuery( 'SELECT u FROM App\Entity\User u WHERE u.email = :email' ); $query->setParameter('email', $email); $users = $query->getResult(); // ✅ ENCORE MIEUX : Utilisation du QueryBuilder $users = $entityManager->getRepository(User::class) ->createQueryBuilder('u') ->where('u.email = :email') ->setParameter('email', $email) ->getQuery() ->getResult(); ``` ### Configuration de la sécurité Doctrine ```yaml # config/packages/doctrine.yaml doctrine: dbal: options: # Active les requêtes préparées 1002: 'SET sql_mode="STRICT_ALL_TABLES"' logging: '%kernel.debug%' profiling: '%kernel.debug%' ``` ## 2. Protection CSRF (Cross-Site Request Forgery) Symfony intègre une protection CSRF native pour tous les formulaires. ### Implémentation dans les formulaires ```php // src/Form/UserProfileType.php use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; class UserProfileType extends AbstractType { public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => User::class, // Protection CSRF activée par défaut 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'user_profile', ]); } } ``` ### Protection CSRF pour les requêtes AJAX ```php // src/Controller/ApiController.php use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; class ApiController extends AbstractController { #[Route('/api/delete-item/{id}', methods: ['DELETE'])] public function deleteItem( int $id, Request $request, CsrfTokenManagerInterface $csrfTokenManager ): JsonResponse { $token = $request->headers->get('X-CSRF-Token'); if (!$csrfTokenManager->isTokenValid(new CsrfToken('delete-item', $token))) { return new JsonResponse(['error' => 'Invalid CSRF token'], 403); } // Traitement de la suppression return new JsonResponse(['success' => true]); } } ``` ```javascript // assets/js/api.js fetch('/api/delete-item/123', { method: 'DELETE', headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content, 'Content-Type': 'application/json' } }); ``` ## 3. Protection XSS (Cross-Site Scripting) Twig, le moteur de template de Symfony, échappe automatiquement les variables. ### Échappement automatique dans Twig ```twig {# ✅ Échappement automatique activé par défaut #}

Bienvenue {{ user.name }}

{# ❌ Désactivation de l'échappement (dangereux) #} {{ user.bio|raw }} {# ✅ Échappement HTML explicite #} {{ user.bio|e('html') }} {# ✅ Échappement JavaScript #} {# ✅ Échappement pour attributs HTML #} ``` ### Content Security Policy (CSP) ```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 onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } $resp>getResponse(); // Content Security Policy $response->headers->set( 'Content-Security-Policy', "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';" ); // Autres headers de sécurité $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('X-XSS-Protection', '1; mode=block'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Permissions-Policy', 'geolocation=(), microph camera=()'); } } ``` ## 4. Authentification et gestion des sessions sécurisées ### Configuration du Security Bundle ```yaml # config/packages/security.yaml security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: 'auto' cost: 15 time_cost: 3 memory_cost: 10 providers: app_user_provider: entity: class: App\Entity\User property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true provider: app_user_provider form_login: login_path: app_login check_path: app_login enable_csrf: true max_attempts: 5 interval: '15 minutes' logout: path: app_logout invalidate_session: true clear_site_data: ['cache', 'cookies', 'storage'] remember_me: secret: '%kernel.secret%' lifetime: 604800 path: / secure: true httponly: true samesite: 'strict' # Protection contre la fixation de session session_fixation_strategy: migrate access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/profile, roles: ROLE_USER } ``` ### Implémentation du Rate Limiting ```bash composer require symfony/rate-limiter ``` ```yaml # config/packages/rate_limiter.yaml framework: rate_limiter: login_attempts: policy: 'sliding_window' limit: 5 interval: '15 minutes' api_requests: policy: 'token_bucket' limit: 100 rate: { interval: '1 hour', amount: 100 } ``` ```php // src/Controller/SecurityController.php use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\RateLimiterFactory; class SecurityController extends AbstractController { #[Route('/login', name: 'app_login')] public function login( Request $request, #[Autowire(service: 'limiter.login_attempts')] RateLimiterFactory $loginLimiter ): Response { $limiter = $loginLimiter->create($request->getClientIp()); if (!$limiter->consume(1)->isAccepted()) { $this->addFlash('error', 'Trop de tentatives. Veuillez réessayer plus tard.'); return $this->redirectToRoute('app_login'); } // Logique de connexion return $this->render('security/login.html.twig'); } } ``` ## 5. Protection contre les attaques par force brute ### Implémentation d'un système de blocage ```php // src/Security/LoginAttemptService.php namespace App\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; class LoginAttemptService { private const MAX_ATTEMPTS = 5; private const BLOCK_DURATION = 900; // 15 minutes public function __construct( private CacheInterface $cache ) {} public function addFailedAttempt(string $identifier): void { $key = $this->getCacheKey($identifier); $attempts = $this->getAttempts($identifier) + 1; $this->cache->get($key, function (ItemInterface $item) use ($attempts) { $item->expiresAfter(self::BLOCK_DURATION); return $attempts; }); } public function resetAttempts(string $identifier): void { $this->cache->delete($this->getCacheKey($identifier)); } public function isBlocked(string $identifier): bool { return $this->getAttempts($identifier) >= self::MAX_ATTEMPTS; } public function getRemainingAttempts(string $identifier): int { return max(0, self::MAX_ATTEMPTS - $this->getAttempts($identifier)); } private function getAttempts(string $identifier): int { return $this->cache->get( $this->getCacheKey($identifier), fn() => 0 ); } private function getCacheKey(string $identifier): string { return 'login_attempts_' . md5($identifier); } } ``` ```php // src/EventListener/LoginFailureListener.php namespace App\EventListener; use App\Security\LoginAttemptService; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; #[AsEventListener(event: LoginFailureEvent::class)] class LoginFailureListener { public function __construct( private LoginAttemptService $loginAttemptService ) {} public function onLoginFailure(LoginFailureEvent $event): void { $request = $event->getRequest(); $identifier = $request->request->get('_username') ?? $request->getClientIp(); $this->loginAttemptService->addFailedAttempt($identifier); } } #[AsEventListener(event: LoginSuccessEvent::class)] class LoginSuccessListener { public function __construct( private LoginAttemptService $loginAttemptService ) {} public function onLoginSuccess(LoginSuccessEvent $event): void { $user = $event->getUser(); $this->loginAttemptService->resetAttempts($user->getUserIdentifier()); } } ``` ## 6. Validation et sanitisation des données ### Utilisation des contraintes Symfony ```php // src/Entity/User.php namespace App\Entity; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[UniqueEntity('email', message: 'Cet email est déjà utilisé')] class User { #[Assert\NotBlank(message: 'L\'email ne peut pas être vide')] #[Assert\Email(message: 'L\'email {{ value }} n\'est pas valide')] #[Assert\Length( max: 180, maxMessage: 'L\'email ne peut pas dépasser {{ limit }} caractères' )] private ?string $email = null; #[Assert\NotBlank(message: 'Le mot de passe ne peut pas être vide')] #[Assert\Length( min: 12, minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères' )] #[Assert\Regex( pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/', message: 'Le mot de passe doit contenir au moins une majuscule, une minuscule, un chiffre et un caractère spécial' )] private ?string $plainPassword = null; #[Assert\NotBlank(message: 'Le nom ne peut pas être vide')] #[Assert\Length( min: 2, max: 50, minMessage: 'Le nom doit contenir au moins {{ limit }} caractères', maxMessage: 'Le nom ne peut pas dépasser {{ limit }} caractères' )] #[Assert\Regex( pattern: '/^[a-zA-ZÀ-ÿ\s-]+$/', message: 'Le nom ne peut contenir que des lettres, espaces et tirets' )] private ?string $name = null; } ``` ### Sanitisation personnalisée ```php // src/Service/InputSanitizer.php namespace App\Service; class InputSanitizer { public function sanitizeHtml(string $input): string { // Supprime les tags HTML dangereux $allowed_tags = '