Symfony
Sécuriser une application Symfony : Guide complet des bonnes pratiques en 2024
15/01/2024
# Introduction
La cybersécurité est devenue un enjeu majeur dans le développement d'applications web. Avec l'augmentation constante des cyberattaques, sécuriser votre application Symfony n'est plus une option mais une nécessité. Dans cet article, nous allons explorer les meilleures pratiques de sécurité pour protéger efficacement vos applications Symfony contre les vulnérabilités les plus courantes.
## 1. Protection contre les injections SQL
Les injections SQL restent l'une des vulnérabilités les plus dangereuses selon l'OWASP Top 10. Avec Symfony et Doctrine, vous disposez d'outils puissants pour vous en protéger.
### Utilisation de Doctrine ORM
```php
// ❌ MAUVAISE PRATIQUE - Vulnérable aux injections SQL
$query = $entityManager->createQuery(
'SELECT u FROM App\Entity\User u WHERE u.email = "' . $email . '"'
);
// ✅ BONNE PRATIQUE - Utilisation de paramètres liés
$query = $entityManager->createQuery(
'SELECT u FROM App\Entity\User u WHERE u.email = :email'
)->setParameter('email', $email);
// ✅ ENCORE MIEUX - Utilisation du QueryBuilder
$user = $entityManager->getRepository(User::class)
->createQueryBuilder('u')
->where('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
```
### Requêtes natives sécurisées
Si vous devez absolument utiliser des requêtes SQL natives :
```php
$c>getConnection();
$sql = 'SELECT * FROM users WHERE email = :email AND status = :status';
$stmt = $conn->prepare($sql);
$result = $stmt->executeQuery([
'email' => $email,
'status' => 'active'
]);
```
## 2. Protection Cross-Site Scripting (XSS)
Le XSS permet à un attaquant d'injecter du code JavaScript malveillant dans vos pages web.
### Configuration Twig sécurisée
Twig échappe automatiquement les variables, mais voici les bonnes pratiques :
```twig
{# ✅ Échappement automatique activé par défaut #}
Bienvenue {{ user.name }}
{# ⚠️ Désactivation de l'échappement (à éviter) #}
{{ content|raw }}
{# ✅ Si vous devez afficher du HTML, utilisez un purificateur #}
{{ content|purify }}
```
### Création d'un service de purification HTML
```php
// src/Service/HtmlPurifier.php
namespace App\Service;
use HTMLPurifier;
use HTMLPurifier_Config;
class HtmlPurifier
{
private HTMLPurifier $purifier;
public function __construct()
{
$c
$config->set('HTML.Allowed', 'p,b,i,strong,em,a[href],ul,ol,li');
$config->set('AutoFormat.AutoParagraph', true);
$this->purifier = new HTMLPurifier($config);
}
public function purify(string $html): string
{
return $this->purifier->purify($html);
}
}
```
### Configuration des en-têtes de sécurité
```yaml
# config/packages/framework.yaml
framework:
csrf_protection: true
# config/packages/security.yaml
security:
headers:
content_security_policy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
x_frame_options: "SAMEORIGIN"
x_content_type_options: "nosniff"
x_xss_protection: "1; mode=block"
referrer_policy: "strict-origin-when-cross-origin"
```
## 3. Protection Cross-Site Request Forgery (CSRF)
Symfony intègre nativement une protection CSRF pour les formulaires.
```php
// src/Form/UserProfileType.php
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class UserProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('username', TextType::class)
->add('email', TextType::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_profile',
]);
}
}
```
### Protection CSRF pour les requêtes AJAX
```php
// src/Controller/ApiController.php
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class ApiController extends AbstractController
{
#[Route('/api/user/update', methods: ['POST'])]
public function updateUser(
Request $request,
CsrfTokenManagerInterface $csrfTokenManager
): JsonResponse {
$token = $request->headers->get('X-CSRF-Token');
if (!$csrfTokenManager->isTokenValid(new CsrfToken('api_update', $token))) {
return new JsonResponse(['error' => 'Invalid CSRF token'], 403);
}
// Traitement sécurisé
return new JsonResponse(['success' => true]);
}
}
```
## 4. Gestion sécurisée des mots de passe
### Hashage des mots de passe
```php
// src/Controller/RegistrationController.php
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', methods: ['POST'])]
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(),
]);
}
}
```
### Configuration du hashage
```yaml
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User:
algorithm: auto
cost: 12 # Pour bcrypt
time_cost: 3 # Pour argon2
memory_cost: 65536 # Pour argon2
```
## 5. Validation et sanitisation des données
### Utilisation des contraintes Symfony
```php
// src/Entity/User.php
use Symfony\Component\Validator\Constraints as Assert;
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)]
private string $email;
#[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 au moins une majuscule, une minuscule, un chiffre et un caractère spécial"
)]
private string $plainPassword;
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 50)]
#[Assert\Regex(
pattern: '/^[a-zA-Z0-9_]+$/',
message: "Le nom d'utilisateur ne peut contenir que des lettres, chiffres et underscores"
)]
private string $username;
}
```
## 6. Sécurisation des uploads de fichiers
```php
// src/Service/FileUploadService.php
namespace App\Service;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
class FileUploadService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
];
private const MAX_FILE_SIZE = 5242880; // 5MB
public function __construct(
private string $uploadDirectory,
private SluggerInterface $slugger
) {}
public function upload(UploadedFile $file): string
{
// Vérification du type MIME
if (!in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES)) {
throw new FileException('Type de fichier non autorisé');
}
// Vérification de la taille
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new FileException('Fichier trop volumineux');
}
// Génération d'un nom de fichier sécurisé
$originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $this->slugger->slug($originalFilename);
$newFilename = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();
try {
$file->move($this->uploadDirectory, $newFilename);
} catch (FileException $e) {
throw new FileException('Erreur lors de l\'upload du fichier');
}
return $newFilename;
}
}
```
## 7. Rate Limiting et protection contre les attaques par force brute
```php
// src/Security/LoginAttemptService.php
namespace App\Security;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Contracts\Cache\ItemInterface;
class LoginAttemptService
{
private const MAX_ATTEMPTS = 5;
private const LOCKOUT_TIME = 900; // 15 minutes
public function __construct(
private RedisAdapter $cache
) {}
public function isBlocked(string $identifier): bool
{
$key = 'login_attempts_' . md5($identifier);
$attempts = $this->cache->get($key, fn() => 0);
return $attempts >= self::MAX_ATTEMPTS;
}
public function recordAttempt(string $identifier): void
{
$key = 'login_attempts_' . md5($identifier);
$attempts = $this->cache->get($key, function(ItemInterface $item) {
$item->expiresAfter(self::LOCKOUT_TIME);
return 0;
});
$this->cache->delete($key);
$this->cache->get($key, function(ItemInterface $item) use ($attempts) {
$item->expiresAfter(self::LOCKOUT_TIME);
return $attempts + 1;
});
}
public function resetAttempts(string $identifier): void
{
$key = 'login_attempts_' . md5($identifier);
$this->cache->delete($key);
}
}
```
### Intégration dans le contrôleur de login
```php
// src/Controller/SecurityController.php
use App\Security\LoginAttemptService;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(
AuthenticationUtils $authenticationUtils,
LoginAttemptService $loginAttemptService,
Request $request
): Response {
$identifier = $request->getClientIp();
if ($loginAttemptService->isBlocked($identifier)) {
$this->addFlash('error', 'Trop de tentatives. Veuillez réessayer dans 15 minutes.');
return $this->render('security/login.html.twig');
}
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
if ($error) {
$loginAttemptService->recordAttempt($identifier);
}
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
}
```
## 8. Sécurisation des variables d'environnement
```yaml
# .env
APP_ENV=prod
APP_SECRET=YOUR_SECURE_SECRET_KEY_HERE
DATABASE_URL="mysql://user:password@127.0.0.1:3306/dbname?serverVersion=8.0"
# Ne jamais commiter .env avec des valeurs de production
# Utiliser .env.local pour les valeurs locales
```
### Utilisation de secrets Symfony
```bash
# Génération de la clé de chiffrement
php bin/console secrets:generate-keys
# Ajout d'un secret
php bin/console secrets:set DATABASE_PASSWORD
# Liste des secrets
php bin/console secrets:list
```
## 9. Audit et logging de sécurité
```php
// src/EventSubscriber/SecurityAuditSubscriber.php
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
class SecurityAuditSubscriber implements EventSubscriberInterface
{
public function __construct(
private LoggerInterface $securityLogger
) {}
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => 'onLoginSuccess',
LoginFailureEvent::class => 'onLoginFailure',
];
}
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();
$this->securityLogger->warning('Échec de connexion', [
'username' => $request->request->get('_username'),
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'reason' => $event->getException()->getMessage(),
'timestamp' => new \DateTime(),
]);
}
}
```
## 10. Checklist de sécurité Symfony
### Configuration de production
✅ **Variables d'environnement**
- APP_ENV=prod
- APP_DEBUG=0
- Secrets chiffrés avec Symfony Secrets
✅ **HTTPS obligatoire**
```yaml
# config/packages/security.yaml
security:
access_control:
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
```
✅ **Mise à jour régulière des dépendances**
```bash
composer audit
composer update
```
✅ **Configuration des en-têtes de sécurité**
- Content-Security-Policy
- X-Frame-Options
- X-Content-Type-Options
- Strict-Transport-Security
✅ **Désactivation des méthodes HTTP non utilisées**
```apache
# .htaccess
Require all denied
```
## Conclusion
La sécurité d'une application Symfony est un processus continu qui nécessite une vigilance constante. Les pratiques présentées dans cet article constituent une base solide pour protéger votre application contre les vulnérabilités les plus courantes.
### Points clés à retenir :
1. **Toujours utiliser des requêtes paramétrées** pour éviter les injections SQL
2. **Activer la protection CSRF** sur tous les formulaires
3. **Valider et sanitiser** toutes les entrées utilisateur
4. **Hasher correctement** les mots de passe avec les algorithmes recommandés
5. **Implémenter le rate limiting** pour protéger contre les attaques par force brute
6. **Configurer les en-têtes de sécurité** appropriés
7. **Logger les événements de sécurité** pour l'audit et la détection d'intrusions
8. **Maintenir les dépendances à jour** et surveiller les vulnérabilités
La sécurité n'est jamais une fonctionnalité à part entière, mais doit être intégrée dès la conception de votre application. Investir du temps dans la sécurisation de votre code vous évitera de nombreux problèmes futurs et protégera vos utilisateurs.
### Ressources complémentaires :
- [Documentation officielle Symfony Security](https://symfony.com/doc/current/security.html)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Symfony Security Checker](https://github.com/fabpot/local-php-security-checker)
- [SensioLabs Security Advisories](https://github.com/FriendsOfPHP/security-advisories)