# 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 = '
- ';
$sanitized = strip_tags($input, $allowed_tags);
// Supprime les attributs dangereux
$sanitized = preg_replace(
'/]*href="(?!https?:\/\/)[^"]*"[^>]*>/i',
'',
$sanitized
);
return trim($sanitized);
}
public function sanitizeFilename(string $filename): string
{
// Supprime les caractères dangereux des noms de fichiers
$sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$sanitized = preg_replace('/(\.\.+)/', '.', $sanitized);
return substr($sanitized, 0, 255);
}
public function sanitizeSqlLike(string $input): string
{
// Échappe les caractères spéciaux pour les requêtes LIKE
return addcslashes($input, '%_\\');
}
}
```
## 7. Sécurisation des uploads de fichiers
```php
// src/Service/SecureFileUploader.php
namespace App\Service;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
class SecureFileUploader
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
];
private const MAX_FILE_SIZE = 5242880; // 5 MB
public function __construct(
private string $targetDirectory,
private SluggerInterface $slugger,
private InputSanitizer $sanitizer
) {}
public function upload(UploadedFile $file): string
{
// Vérification de la taille
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new FileException('Le fichier est trop volumineux');
}
// Vérification du type MIME
if (!in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true)) {
throw new FileException('Type de fichier non autorisé');
}
// Vérification de l'extension
$extension = $file->guessExtension();
if (!$extension) {
$extension = 'bin';
}
// Génération d'un nom de fichier sécurisé
$originalFilename = pathinfo(
$file->getClientOriginalName(),
PATHINFO_FILENAME
);
$safeFilename = $this->sanitizer->sanitizeFilename($originalFilename);
$safeFilename = $this->slugger->slug($safeFilename);
$fileName = $safeFilename . '-' . uniqid() . '.' . $extension;
try {
$file->move($this->targetDirectory, $fileName);
// Vérification supplémentaire du contenu
$this->validateFileContent($this->targetDirectory . '/' . $fileName);
} catch (FileException $e) {
throw new FileException('Erreur lors de l\'upload du fichier');
}
return $fileName;
}
private function validateFileContent(string $filePath): void
{
// Vérification que le fichier n'est pas un script PHP déguisé
$c false, null, 0, 1024);
if (preg_match('/request('GET', '/search', [
'q' => "'; DROP TABLE users; --"
]);
$this->assertResponseIsSuccessful();
// Vérifier que la table existe toujours
}
public function testXssProtection(): void
{
$client = static::createClient();
$client->request('POST', '/comment', [
'content' => ''
]);
$crawler = $client->request('GET', '/comments');
$c>filter('.comment')->text();
// Vérifier que le script n'est pas exécuté
$this->assertStringNotContainsString('', $content);
}
public function testRateLimiting(): void
{
$client = static::createClient();
// Effectuer plusieurs requêtes rapidement
for ($i = 0; $i < 10; $i++) {
$client->request('POST', '/login', [
'_username' => 'test@example.com',
'_password' => 'wrong_password'
]);
}
// La dernière requête devrait être bloquée
$this->assertResponseStatusCodeSame(429);
}
}
```
## Conclusion
La sécurité d'une application Symfony nécessite une approche multi-couches :
1. **Utiliser les outils natifs** : Symfony offre une protection robuste par défaut
2. **Valider et échapper toutes les entrées** : Ne jamais faire confiance aux données utilisateur
3. **Implémenter des contrôles d'accès stricts** : Principe du moindre privilège
4. **Monitorer et logger** : Détecter rapidement les anomalies
5. **Maintenir à jour** : Appliquer régulièrement les patches de sécurité
6. **Tester régulièrement** : Automatiser les tests de sécurité
### Ressources supplémentaires
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Symfony Security Documentation](https://symfony.com/doc/current/security.html)
- [Symfony Security Best Practices](https://symfony.com/doc/current/best_practices.html#security)
- [Snyk - Security Scanner](https://snyk.io/)
### Commandes utiles
```bash
# Audit de sécurité des dépendances
composer audit
symfony security:check
# Analyse statique de code
vendor/bin/phpstan analyse src
vendor/bin/psalm
# Tests de sécurité
php bin/phpunit --testsuite=security
```
La sécurité n'est pas un état mais un processus continu. Restez vigilant, formez votre équipe et suivez les bonnes pratiques pour protéger vos applications et vos utilisateurs.