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

# Introduction La sécurité des applications web est devenue une préoccupation majeure en 2024. Symfony, l'un des frameworks PHP les plus robustes, offre de nom

Symfony

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

15/01/2024

# Introduction La sécurité des applications web est devenue une préoccupation majeure en 2024. Symfony, l'un des frameworks PHP les plus robustes, offre de nombreux mécanismes de sécurité intégrés. Cet article explore comment protéger efficacement votre application Symfony contre les vulnérabilités du OWASP Top 10. ## 1. Protection contre les injections SQL Symfony utilise Doctrine ORM qui protège naturellement contre les injections SQL grâce aux requêtes préparées. ### Mauvaise pratique : ```php // DANGEREUX - Vulnerable à l'injection SQL $query = $entityManager->createQuery( 'SELECT u FROM App\Entity\User u WHERE u.email = "' . $email . '"' ); ``` ### Bonne pratique : ```php // SÉCURISÉ - Utilisation de paramètres $query = $entityManager->createQuery( 'SELECT u FROM App\Entity\User u WHERE u.email = :email' ); $query->setParameter('email', $email); // Ou avec le QueryBuilder (recommandé) $user = $entityManager->getRepository(User::class) ->createQueryBuilder('u') ->where('u.email = :email') ->setParameter('email', $email) ->getQuery() ->getOneOrNullResult(); ``` ## 2. Protection CSRF (Cross-Site Request Forgery) Symfony intègre une protection CSRF native pour les formulaires. ### Configuration dans config/packages/framework.yaml : ```yaml framework: csrf_protection: true ``` ### Implémentation dans un formulaire : ```php use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolver; class UserType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('email', TextType::class) ->add('password', PasswordType::class); // Le token CSRF est automatiquement ajouté } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => User::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'user_item', ]); } } ``` ### Protection CSRF pour les requêtes AJAX : ```php // Dans votre contrôleur use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Route('/api/delete/{id}', name: 'api_delete', methods: ['DELETE'])] public function delete( Request $request, CsrfTokenManagerInterface $csrfTokenManager, int $id ): 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 sécurisé } ``` ## 3. Authentification et gestion des mots de passe ### Configuration du Security Bundle : ```yaml # config/packages/security.yaml security: password_hashers: App\Entity\User: algorithm: auto # Utilise bcrypt, argon2i ou argon2id cost: 12 # Pour bcrypt 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 logout: path: app_logout target: app_home remember_me: secret: '%kernel.secret%' lifetime: 604800 secure: true # HTTPS uniquement httponly: true # Pas accessible via JavaScript samesite: 'strict' access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/profile, roles: ROLE_USER } ``` ### Hashage sécurisé des mots de passe : ```php use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class RegistrationController extends AbstractController { #[Route('/register', name: 'app_register')] public function register( Request $request, UserPasswordHasherInterface $passwordHasher, EntityManagerInterface $entityManager ): Response { $user = new User(); $form = $this->createForm(RegistrationFormType::class, $user); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // Hash du mot de passe $hashedPassword = $passwordHasher->hashPassword( $user, $form->get('plainPassword')->getData() ); $user->setPassword($hashedPassword); $entityManager->persist($user); $entityManager->flush(); return $this->redirectToRoute('app_login'); } return $this->render('registration/register.html.twig', [ 'registrationForm' => $form->createView(), ]); } } ``` ## 4. Protection XSS (Cross-Site Scripting) Twig, le moteur de templates de Symfony, échappe automatiquement les variables. ### Échappement automatique dans Twig : ```twig {# SÉCURISÉ - Échappement automatique #}

Bonjour {{ user.name }}

{# Si vous devez afficher du HTML de confiance #} {{ content|raw }} {# Échappement pour JavaScript #} {# Échappement pour attributs HTML #} ``` ### Content Security Policy (CSP) : ```php // src/EventListener/SecurityHeadersListener.php namespace App\EventListener; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: 'kernel.response')] class SecurityHeadersListener { public function onKernelResponse(ResponseEvent $event): void { $resp>getResponse(); // Content Security Policy $response->headers->set( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" ); // 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=()'); // HTTPS strict $response->headers->set( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); } } ``` ## 5. Validation et sanitisation des données ### Utilisation des contraintes de validation : ```php // src/Entity/User.php namespace App\Entity; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[UniqueEntity(fields: ['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] #[Assert\Length( min: 8, max: 4096, 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 une majuscule, une minuscule, un chiffre et un caractère spécial' )] private ?string $plainPassword = null; #[Assert\NotBlank] #[Assert\Length(min: 2, max: 100)] #[Assert\Regex( pattern: '/^[a-zA-ZÀ-ÿ\s-]+$/', message: 'Le nom ne peut contenir que des lettres, espaces et tirets' )] private ?string $name = null; } ``` ### Validation dans les contrôleurs : ```php use Symfony\Component\Validator\Validator\ValidatorInterface; #[Route('/api/user', name: 'api_user_create', methods: ['POST'])] public function create( Request $request, ValidatorInterface $validator, EntityManagerInterface $entityManager ): JsonResponse { $data = json_decode($request->getContent(), true); $user = new User(); $user->setEmail($data['email'] ?? ''); $user->setName($data['name'] ?? ''); // Validation $errors = $validator->validate($user); if (count($errors) > 0) { $errorMessages = []; foreach ($errors as $error) { $errorMessages[$error->getPropertyPath()] = $error->getMessage(); } return new JsonResponse(['errors' => $errorMessages], 400); } $entityManager->persist($user); $entityManager->flush(); return new JsonResponse(['id' => $user->getId()], 201); } ``` ## 6. Contrôle d'accès et autorisation ### Utilisation des Voters pour une logique d'autorisation complexe : ```php // src/Security/Voter/PostVoter.php namespace App\Security\Voter; use App\Entity\Post; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; class PostVoter extends Voter { const VIEW = 'view'; const EDIT = 'edit'; const DELETE = 'delete'; protected function supports(string $attribute, mixed $subject): bool { return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE]) && $subject instanceof Post; } protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token ): bool { $user = $token->getUser(); if (!$user instanceof User) { return false; } /** @var Post $post */ $post = $subject; return match($attribute) { self::VIEW => $this->canView($post, $user), self::EDIT => $this->canEdit($post, $user), self::DELETE => $this->canDelete($post, $user), default => false, }; } private function canView(Post $post, User $user): bool { // Les admins peuvent tout voir if (in_array('ROLE_ADMIN', $user->getRoles())) { return true; } // Les posts publiés sont visibles par tous if ($post->isPublished()) { return true; } // Les auteurs peuvent voir leurs propres brouillons return $post->getAuthor() === $user; } private function canEdit(Post $post, User $user): bool { return $user === $post->getAuthor() || in_array('ROLE_ADMIN', $user->getRoles()); } private function canDelete(Post $post, User $user): bool { return in_array('ROLE_ADMIN', $user->getRoles()); } } ``` ### Utilisation dans les contrôleurs : ```php use Symfony\Component\Security\Http\Attribute\IsGranted; class PostController extends AbstractController { #[Route('/post/{id}', name: 'post_show')] public function show(Post $post): Response { // Vérifie l'autorisation $this->denyAccessUnlessGranted('view', $post); return $this->render('post/show.html.twig', [ 'post' => $post, ]); } #[Route('/post/{id}/edit', name: 'post_edit')] #[IsGranted('edit', subject: 'post')] public function edit(Post $post, Request $request): Response { // Le voter a déjà vérifié les permissions $form = $this->createForm(PostType::class, $post); // ... } } ``` ## 7. Protection contre les attaques par force brute ### Rate Limiting avec le composant RateLimiter : ```bash composer require symfony/rate-limiter ``` ### Configuration : ```yaml # config/packages/rate_limiter.yaml framework: rate_limiter: login: policy: 'sliding_window' limit: 5 interval: '15 minutes' api: policy: 'token_bucket' limit: 100 rate: { interval: '1 hour' } ``` ### Implémentation : ```php use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; class LoginController extends AbstractController { #[Route('/login', name: 'app_login', methods: ['POST'])] public function login( Request $request, RateLimiterFactory $loginLimiter ): Response { $limiter = $loginLimiter->create($request->getClientIp()); if (false === $limiter->consume(1)->isAccepted()) { throw new TooManyRequestsHttpException( null, 'Trop de tentatives de connexion. Veuillez réessayer plus tard.' ); } // Logique de connexion } } ``` ## 8. Sécurisation des sessions ### Configuration des sessions : ```yaml # config/packages/framework.yaml framework: session: handler_id: null cookie_secure: auto # Force HTTPS en production cookie_httponly: true cookie_samesite: 'lax' gc_probability: 1 gc_divisor: 100 gc_maxlifetime: 3600 # 1 heure name: 'SECURE_SESSION_ID' # Nom personnalisé ``` ### Régénération de l'ID de session après connexion : ```php use Symfony\Component\HttpFoundation\RequestStack; class SecurityService { public function __construct( private RequestStack $requestStack ) {} public function regenerateSession(): void { $session = $this->requestStack->getSession(); // Sauvegarde des données importantes $data = $session->all(); // Régénère l'ID $session->invalidate(); // Restaure les données foreach ($data as $key => $value) { $session->set($key, $value); } } } ``` ## 9. Logging et monitoring de sécurité ### Configuration Monolog pour les événements de sécurité : ```yaml # config/packages/monolog.yaml monolog: channels: ['security'] handlers: security: type: stream path: '%kernel.logs_dir%/security.log' level: info channels: ['security'] security_errors: type: stream path: '%kernel.logs_dir%/security_errors.log' level: error channels: ['security'] ``` ### Event Listener pour logger les tentatives de connexion : ```php // src/EventListener/SecurityEventsListener.php namespace App\EventListener; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; #[AsEventListener(event: LoginSuccessEvent::class)] #[AsEventListener(event: LoginFailureEvent::class)] class SecurityEventsListener { public function __construct( private LoggerInterface $securityLogger ) {} public function onLoginSuccess(LoginSuccessEvent $event): void { $user = $event->getUser(); $request = $event->getRequest(); $this->securityLogger->info('Connexion réussie', [ 'username' => $user->getUserIdentifier(), 'ip' => $request->getClientIp(), 'user_agent' => $request->headers->get('User-Agent'), 'timestamp' => new \DateTime(), ]); } public function onLoginFailure(LoginFailureEvent $event): void { $request = $event->getRequest(); $exception = $event->getException(); $this->securityLogger->warning('Échec de connexion', [ 'username' => $request->request->get('_username'), 'ip' => $request->getClientIp(), 'reason' => $exception->getMessage(), 'user_agent' => $request->headers->get('User-Agent'), 'timestamp' => new \DateTime(), ]); } } ``` ## 10. Sécurisation des API ### Authentification JWT : ```bash composer require lexik/jwt-authentication-bundle ``` ### Configuration : ```yaml # config/packages/lexik_jwt_authentication.yaml lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' token_ttl: 3600 ``` ### Configuration du firewall : ```yaml # config/packages/security.yaml security: firewalls: api: pattern: ^/api stateless: true jwt: ~ ``` ### Contrôleur d'authentification API : ```php namespace App\Controller\Api; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\Security\Core\User\UserInterface; class AuthController extends AbstractController { #[Route('/api/login', name: 'api_login', methods: ['POST'])] public function login( Request $request, UserRepository $userRepository, UserPasswordHasherInterface $passwordHasher, JWTTokenManagerInterface $JWTManager ): JsonResponse { $data = json_decode($request->getContent(), true); $user = $userRepository->findOneBy(['email' => $data['email'] ?? '']); if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'] ?? '')) { return new JsonResponse(['error' => 'Invalid credentials'], 401); } $token = $JWTManager->create($user); return new JsonResponse([ 'token' => $token, 'refresh_token' => $this->generateRefreshToken($user), ]); } } ``` ## Checklist de sécurité Symfony ### Avant de mettre en production : - [ ] Activer HTTPS avec certificat SSL/TLS valide - [ ] Configurer les headers de sécurité (CSP, HSTS, X-Frame-Options) - [ ] Vérifier que `APP_ENV=prod` et `APP_DEBUG=false` - [ ] Désactiver le profiler et le Web Debug Toolbar - [ ] Utiliser des secrets sécurisés (Vault, variables d'environnement) - [ ] Configurer le CSRF pour tous les formulaires - [ ] Implémenter le rate limiting sur les endpoints sensibles - [ ] Valider et sanitiser toutes les entrées utilisateur - [ ] Utiliser les requêtes préparées pour toutes les requêtes SQL - [ ] Configurer correctement les permissions de fichiers (var/, config/) - [ ] Activer les logs de sécurité et configurer le monitoring - [ ] Mettre à jour régulièrement Symfony et ses dépendances - [ ] Effectuer un audit de sécurité avec `symfony security:check` - [ ] Configurer des sauvegardes automatiques de la base de données - [ ] Implémenter une politique de mots de passe forte - [ ] Tester avec OWASP ZAP ou un scanner de vulnérabilités ## Outils de sécurité recommandés ### Scanner de vulnérabilités : ```bash # Symfony CLI security checker symfony security:check # Local PHP Security Checker composer require --dev local-php-security-checker local-php-security-checker # Audit des dépendances composer audit ``` ### Analyse statique du code : ```bash composer require --dev phpstan/phpstan vendor/bin/phpstan analyse src composer require --dev vimeo/psalm vendor/bin/psalm --init vendor/bin/psalm ``` ## Conclusion La sécurité est un processus continu, pas une fonctionnalité qu'on ajoute à la fin. Symfony offre des outils puissants pour sécuriser vos applications, mais ils doivent être correctement configurés et utilisés. Les points clés à retenir : 1. **Toujours valider et sanitiser** les entrées utilisateur 2. **Ne jamais faire confiance** aux données côté client 3. **Utiliser les composants Symfony** plutôt que de réinventer la roue 4. **Maintenir à jour** vos dépendances régulièrement 5. **Logger les événements de sécurité** pour détecter les attaques 6. **Tester régulièrement** vos applications avec des outils d'audit 7. **Former votre équipe** aux bonnes pratiques de sécurité La sécurité doit être intégrée dès le début du développement et réévaluée régulièrement. Utilisez les audits de sécurité, effectuez des tests de pénétration et restez informé des nouvelles vulnérabilités. N'oubliez pas : une application sécurisée est une application qui protège à la fois vos utilisateurs et votre entreprise.