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 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 allons voir 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 lorsque vous utilisez correctement les paramètres préparés.
Bonne pratique avec Doctrine
// ❌ 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 Doctrine
# config/packages/doctrine.yaml
doctrine:
dbal:
options:
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 les formulaires. Il est important de l’activer et de la vérifier également dans les actions sensibles, notamment côté AJAX et API internes.
Implémentation dans les formulaires
// src/Form/UserProfileType.php
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserProfileType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$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
// src/Controller/ApiController.php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Csrf\CsrfToken;
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]);
}
}
// 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. C’est une protection essentielle contre les attaques XSS.
Échappement automatique dans Twig
{# ✅ Échappement automatique activé par défaut #}
<h1>Bienvenue {{ user.name }}</h1>
{# ❌ Désactivation de l'échappement (dangereux) #}
<div>{{ user.bio|raw }}</div>
{# ✅ Échappement HTML explicite #}
<div>{{ user.bio|e('html') }}</div>
{# ✅ Échappement JavaScript #}
<script>
const userName = "{{ user.name|e('js') }}";
</script>
{# ✅ Échappement pour attributs HTML #}
<div data-name="{{ user.name|e('html_attr') }}"></div>
Content Security Policy (CSP)
// 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
$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';"
);
$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
Une authentification bien configurée est indispensable pour protéger l’accès à votre application et limiter les abus.
Configuration du Security Bundle
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: 'auto'
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
invalidate_session: true
clear_site_data: ['cache', 'cookies', 'storage']
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
secure: true
httponly: true
samesite: 'strict'
session_fixation_strategy: migrate
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
Implémentation du Rate Limiting
composer require symfony/rate-limiter
# 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 }
// 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');
}
return $this->render('security/login.html.twig');
}
}
5. Protection contre les attaques par force brute
En complément du rate limiting, vous pouvez mettre en place un système de blocage temporaire après plusieurs tentatives de connexion échouées.
Implémentation d’un système de blocage
// 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->delete($key);
$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);
}
}
// 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
La validation côté serveur reste indispensable, même si vous validez déjà côté frontend. Ne faites jamais confiance aux données fournies par l’utilisateur.
Utilisation des contraintes Symfony
// 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
// src/Service/InputSanitizer.php
namespace App\Service;
class InputSanitizer
{
public function sanitizeHtml(string $input): string
{
$allowed_tags = '<p><br><strong><em><ul><ol><li><a>';
$sanitized = strip_tags($input, $allowed_tags);
$sanitized = preg_replace(
'/<a[^>]*href="(?!https?:\\/\\/)[^"]*"[^>]*>/i',
'<a>',
$sanitized
);
return trim($sanitized);
}
public function sanitizeFilename(string $filename): string
{
$sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$sanitized = preg_replace('/(\\.\\.+)/', '.', $sanitized);
return substr($sanitized, 0, 255);
}
public function sanitizeSqlLike(string $input): string
{
return addcslashes($input, '%_\\\\');
}
}
7. Sécurisation des uploads de fichiers
L’upload de fichiers est une zone critique. Il faut contrôler la taille, le type MIME, l’extension et idéalement stocker les fichiers hors du répertoire public lorsque c’est possible.
// 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
{
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new FileException('Le fichier est trop volumineux');
}
if (!in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true)) {
throw new FileException('Type de fichier non autorisé');
}
$extension = $file->guessExtension();
if (!$extension) {
$extension = 'bin';
}
$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);
$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
{
$c false, null, 0, 1024);
if (preg_match('/<\\?php|<\\?=/i', $content)) {
unlink($filePath);
throw new FileException('Fichier suspect détecté');
}
}
public function getTargetDirectory(): string
{
return $this->targetDirectory;
}
}
8. Logging et monitoring de sécurité
Le logging permet de détecter rapidement les tentatives suspectes, d’analyser les incidents et de renforcer votre posture de sécurité.
# config/packages/monolog.yaml
monolog:
channels: ['security', 'deprecation']
handlers:
security:
type: stream
path: "%kernel.logs_dir%/security.log"
level: warning
channels: ['security']
security_failed_login:
type: stream
path: "%kernel.logs_dir%/auth_failures.log"
level: info
channels: ['security']
// 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: LoginFailureEvent::class)]
class SecurityEventsListener
{
public function __construct(
private LoggerInterface $securityLogger
) {}
public function onLoginFailure(LoginFailureEvent $event): void
{
$request = $event->getRequest();
$this->securityLogger->warning('Failed login attempt', [
'username' => $request->request->get('_username'),
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'timestamp' => new \DateTime(),
]);
}
}
#[AsEventListener(event: LoginSuccessEvent::class)]
class LoginSuccessListener
{
public function __construct(
private LoggerInterface $securityLogger
) {}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
$request = $event->getRequest();
$this->securityLogger->info('Successful login', [
'username' => $user->getUserIdentifier(),
'ip' => $request->getClientIp(),
'timestamp' => new \DateTime(),
]);
}
}
9. Configuration de production sécurisée
La production doit être configurée différemment de l’environnement de développement. Les messages d’erreur détaillés, le debug et certaines informations sensibles ne doivent jamais être exposés.
# config/packages/prod/framework.yaml
framework:
profiler:
enabled: false
session:
cookie_secure: 'auto'
cookie_httponly: true
cookie_samesite: 'strict'
handler_id: null
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
cache:
app: cache.adapter.redis
default_redis_provider: 'redis://localhost'
# .env.prod
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=changez-moi-avec-une-vraie-valeur-aleatoire-de-32-caracteres
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=8.0&charset=utf8mb4"
Checklist de sécurité pour la production
# .env
APP_ENV=prod
APP_DEBUG=0
# Vérification des dépendances
composer audit
# Mise à jour des dépendances de sécurité
composer update --with-dependencies
# Nettoyage du cache
php bin/console cache:clear --env=prod --no-debug
# Vérification de la configuration de sécurité
php bin/console debug:config security
# Vérification des routes exposées
php bin/console debug:router
10. Tests de sécurité automatisés
Les tests automatisés permettent de vérifier régulièrement que les protections de sécurité sont bien en place et qu’aucune régression n’a été introduite.
// tests/Security/SecurityTest.php
namespace App\Tests\Security;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SecurityTest extends WebTestCase
{
public function testCsrfProtectionOnForms(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/login');
$this->assertResponseIsSuccessful();
$this->assertCount(1, $crawler->filter('input[name="_csrf_token"]'));
}
public function testSecurityHeaders(): void
{
$client = static::createClient();
$client->request('GET', '/');
$resp
$this->assertTrue($response->headers->has('X-Content-Type-Options'));
$this->assertEquals('nosniff', $response->headers->get('X-Content-Type-Options'));
$this->assertTrue($response->headers->has('X-Frame-Options'));
$this->assertEquals('DENY', $response->headers->get('X-Frame-Options'));
$this->assertTrue($response->headers->has('Content-Security-Policy'));
}
public function testSqlInjectionProtection(): void
{
$client = static::createClient();
$client->request('GET', '/search', [
'q' => "'; DROP TABLE users; --"
]);
$this->assertResponseIsSuccessful();
}
public function testXssProtection(): void
{
$client = static::createClient();
$client->request('POST', '/comment', [
'content' => '<script>alert("XSS")</script>'
]);
$crawler = $client->request('GET', '/comments');
$c
$this->assertStringNotContainsString('<script>', $content);
}
public function testRateLimiting(): void
{
$client = static::createClient();
for ($i = 0; $i < 10; $i++) {
$client->request('POST', '/login', [
'_username' => 'test@example.com',
'_password' => 'wrong_password'
]);
}
$this->assertResponseStatusCodeSame(429);
}
}
Conclusion
La sécurité d’une application Symfony nécessite une approche multi-couches. Il ne suffit pas d’activer quelques protections par défaut : il faut penser validation, authentification, journalisation, configuration de production et tests automatisés.
- Utiliser les outils natifs de Symfony : le framework fournit déjà une base solide.
- Valider et échapper toutes les entrées : aucune donnée utilisateur ne doit être considérée comme fiable.
- Mettre en place des contrôles d’accès stricts : appliquez le principe du moindre privilège.
- Monitorer et journaliser : détectez rapidement les anomalies et les comportements suspects.
- Maintenir votre application à jour : appliquez régulièrement les correctifs de sécurité.
- Tester régulièrement : automatisez autant que possible vos contrôles de sécurité.
Ressources supplémentaires
Commandes utiles
# 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 appliquez les bonnes pratiques pour protéger vos applications ainsi que vos utilisateurs.