Introduction
La sécurité des applications web est devenue un enjeu critique en 2026. Avec l'augmentation des cyberattaques sophistiquées et l'adoption massive de Docker pour le déploiement d'applications Symfony, il est essentiel de comprendre comment combiner ces technologies de manière sécurisée. Cet article explore les meilleures pratiques pour sécuriser une application Symfony conteneurisée avec Docker, en couvrant à la fois les aspects infrastructure et applicatifs.
Nous aborderons les vulnérabilités courantes, les configurations sécurisées, et les outils modernes pour protéger votre stack technique de bout en bout.
1. Sécurisation de l'Image Docker
1.1 Utiliser des Images de Base Minimales
L'une des premières règles de sécurité Docker est de minimiser la surface d'attaque en utilisant des images de base légères.
# Dockerfile - Mauvaise pratique
FROM php:8.3-apache
# Dockerfile - Bonne pratique
FROM php:8.3-fpm-alpine
# Installation des dépendances minimales nécessaires
RUN apk add --no-cache \
git \
unzip \
libzip-dev \
postgresql-dev \
&& docker-php-ext-install pdo_pgsql zip opcacheLes images Alpine Linux sont particulièrement recommandées car elles ne contiennent que l'essentiel, réduisant ainsi les vulnérabilités potentielles.
1.2 Scanner les Vulnérabilités avec Trivy
Intégrez un scanner de vulnérabilités dans votre pipeline CI/CD pour détecter les failles de sécurité.
# Installation de Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scanner l'image Docker
trivy image --severity HIGH,CRITICAL mon-app-symfony:latest
# Dans votre .gitlab-ci.yml ou GitHub Actions
security-scan:
stage: security
image: aquasec/trivy:latest
script:
- trivy image --exit-code 1 --severity CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only:
- merge_requests
- main1.3 Utiliser un Utilisateur Non-Root
Ne jamais exécuter vos conteneurs en tant que root. Créez un utilisateur dédié avec des permissions limitées.
# Dockerfile
FROM php:8.3-fpm-alpine
# Créer un utilisateur non-privilégié
RUN addgroup -g 1001 symfony && \
adduser -D -u 1001 -G symfony symfony
# Installation de Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# Définir le répertoire de travail
WORKDIR /var/www/symfony
# Copier les fichiers avec le bon propriétaire
COPY --chown=symfony:symfony . .
# Installer les dépendances
USER symfony
RUN composer install --no-dev --optimize-autoloader
# Exposer le port (non-privilégié)
EXPOSE 9000
CMD ["php-fpm"]2. Configuration Sécurisée de Symfony
2.1 Gestion des Secrets avec Docker Secrets ou Vault
Ne jamais stocker de secrets en clair dans vos fichiers de configuration ou variables d'environnement classiques.
# docker-compose.yml - Utilisation de Docker Secrets
version: '3.8'
services:
app:
build: .
secrets:
- db_password
- app_secret
- jwt_secret
environment:
DATABASE_URL: postgresql://user:run/secrets/db_password@db:5432/mydb
APP_SECRET_FILE: /run/secrets/app_secret
secrets:
db_password:
file: ./secrets/db_password.txt
app_secret:
file: ./secrets/app_secret.txt
jwt_secret:
file: ./secrets/jwt_secret.txt# config/services.yaml - Lecture des secrets depuis fichiers
parameters:
app.secret: '%env(file:resolve:APP_SECRET_FILE)%'
database.password: '%env(file:resolve:DB_PASSWORD_FILE)%'
services:
_defaults:
autowire: true
autoconfigure: true2.2 Configuration du Security Bundle
Configurez correctement le composant de sécurité de Symfony avec des algorithmes robustes et des politiques strictes.
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: 'sodium'
migrate_from:
- 'bcrypt'
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~
entry_point: jwt
refresh_jwt:
check_path: /api/token/refresh
main:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\LoginAuthenticator
logout:
path: app_logout
target: app_login
login_throttling:
max_attempts: 5
interval: '15 minutes'
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
secure: true
httponly: true
samesite: 'strict'
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH]2.3 Protection CSRF et Headers de Sécurité
# config/packages/framework.yaml
framework:
csrf_protection: true
http_method_override: false
# 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 __invoke(ResponseEvent $event): void
{
$resp>getResponse();
// Protection contre le clickjacking
$response->headers->set('X-Frame-Options', 'DENY');
// Content Security Policy stricte
$response->headers->set('Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: https:; " .
"font-src 'self'; " .
"connect-src 'self'; " .
"frame-ancestors 'none';"
);
// Protection XSS
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
// HTTPS strict
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Permissions Policy
$response->headers->set('Permissions-Policy', 'geolocation=(), microph camera=()');
// Referrer Policy
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
}3. Sécurisation de Docker Compose et Réseau
3.1 Isolation Réseau
Isolez vos services dans des réseaux Docker dédiés pour limiter les communications inter-conteneurs.
# docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "443:443"
networks:
- frontend
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- app
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
app:
build:
context: .
dockerfile: Dockerfile
networks:
- frontend
- backend
environment:
- APP_ENV=prod
- APP_DEBUG=0
secrets:
- db_password
- app_secret
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
- /var/www/symfony/var/cache
- /var/www/symfony/var/log
db:
image: postgres:16-alpine
networks:
- backend
environment:
POSTGRES_DB: symfony_db
POSTGRES_USER: symfony_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- db_data:/var/lib/postgresql/data:rw
secrets:
- db_password
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETUID
- SETGID
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # Pas d'accès Internet
volumes:
db_data:
driver: local
secrets:
db_password:
file: ./secrets/db_password.txt
app_secret:
file: ./secrets/app_secret.txt3.2 Configuration Nginx Sécurisée
# nginx/conf.d/default.conf
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/symfony/public;
# Certificats SSL/TLS
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Configuration SSL moderne
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;
# Headers de sécurité
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cacher la version Nginx
server_tokens off;
# Limitation de taille des uploads
client_max_body_size 10M;
client_body_buffer_size 128k;
# Limitation du taux de requêtes
limit_req_zone $binary_remote_addr z rate=5r/m;
limit_req_zone $binary_remote_addr z rate=100r/s;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
limit_req z burst=20 nodelay;
fastcgi_pass app:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param HTTPS on;
# Timeouts
fastcgi_read_timeout 300;
fastcgi_send_timeout 300;
internal;
}
location ~ /\.(ht|git|env) {
deny all;
}
location ~ \.php$ {
return 404;
}
}4. Validation et Sanitisation des Entrées
4.1 Utilisation des Validators Symfony
# src/Entity/User.php
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[UniqueEntity('email')]
class User
{
#[Assert\NotBlank]
#[Assert\Email(mode: 'strict')]
#[Assert\Length(max: 180)]
private ?string $email = null;
#[Assert\NotBlank(groups: ['registration'])]
#[Assert\Length(min: 12, max: 4096)]
#[Assert\NotCompromisedPassword]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)]
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;
}4.2 Protection contre les Injections SQL
# src/Repository/ProductRepository.php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
// MAUVAISE PRATIQUE - Vulnérable aux injections SQL
public function findByNameUnsafe(string $name): array
{
$sql = "SELECT * FROM product WHERE name LIKE '%" . $name . "%'";
return $this->getEntityManager()->getConnection()->executeQuery($sql)->fetchAllAssociative();
}
// BONNE PRATIQUE - Utilisation de requêtes paramétrées
public function findByNameSafe(string $name): array
{
return $this->createQueryBuilder('p')
->where('p.name LIKE :name')
->setParameter('name', '%' . $name . '%')
->getQuery()
->getResult();
}
// BONNE PRATIQUE - Avec Doctrine DBAL
public function findByCategory(int $categoryId): array
{
$c>getEntityManager()->getConnection();
$sql = 'SELECT * FROM product WHERE category_id = :categoryId';
return $conn->executeQuery($sql, ['categoryId' => $categoryId])->fetchAllAssociative();
}
}5. Surveillance et Monitoring
5.1 Intégration de Logs Sécurisés
# config/packages/monolog.yaml
monolog:
channels: ['security', 'deprecation']
handlers:
security:
type: stream
path: "%kernel.logs_dir%/security_%kernel.environment%.log"
level: info
channels: [security]
formatter: monolog.formatter.json
failed_login:
type: fingers_crossed
action_level: error
handler: grouped_failed_login
channels: [security]
grouped_failed_login:
type: group
members: [streamed, syslog]
streamed:
type: stream
path: "%kernel.logs_dir%/failed_auth.log"
formatter: monolog.formatter.json# 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)]
#[AsEventListener(event: LoginSuccessEvent::class)]
class SecurityEventsListener
{
public function __construct(
private LoggerInterface $securityLogger
) {}
public function __invoke(LoginFailureEvent|LoginSuccessEvent $event): void
{
$request = $event->getRequest();
$c
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'timestamp' => (new \DateTime())->format('c'),
];
if ($event instanceof LoginFailureEvent) {
$context['username'] = $event->getPassport()?->getUser()?->getUserIdentifier();
$context['exception'] = $event->getException()->getMessage();
$this->securityLogger->warning('Failed login attempt', $context);
} elseif ($event instanceof LoginSuccessEvent) {
$context['username'] = $event->getUser()->getUserIdentifier();
$this->securityLogger->info('Successful login', $context);
}
}
}5.2 Configuration de Fail2Ban
# /etc/fail2ban/filter.d/symfony-auth.conf
[Definition]
failregex = ^.*"message":"Failed login attempt".*"ip":"".*$
ignoreregex =
# /etc/fail2ban/jail.d/symfony.conf
[symfony-auth]
enabled = true
filter = symfony-auth
logpath = /var/www/symfony/var/log/failed_auth.log
maxretry = 5
findtime = 600
bantime = 3600
action = iptables-multiport[name=symfony, port="http,https", protocol=tcp]6. Checklist de Sécurité Complète
Infrastructure Docker
- ✓ Images minimales : Utiliser Alpine Linux ou Distroless
- ✓ Scan de vulnérabilités : Intégrer Trivy ou Snyk dans le CI/CD
- ✓ Utilisateur non-root : Tous les conteneurs s'exécutent avec des utilisateurs dédiés
- ✓ Capabilities Linux : Drop ALL et n'ajouter que le nécessaire
- ✓ Read-only filesystem : Conteneurs en lecture seule avec tmpfs pour cache
- ✓ Secrets management : Docker Secrets ou HashiCorp Vault
- ✓ Isolation réseau : Réseaux séparés frontend/backend
- ✓ Resource limits : CPU et mémoire limités
Application Symfony
- ✓ APP_ENV=prod : Jamais de mode debug en production
- ✓ HTTPS obligatoire : Redirection et HSTS activés
- ✓ Security headers : CSP, X-Frame-Options, etc.
- ✓ CSRF protection : Activée sur tous les formulaires
- ✓ Password hashing : Algorithme sodium ou argon2i
- ✓ Rate limiting : Sur les endpoints sensibles (login, API)
- ✓ Input validation : Validators sur toutes les entrées
- ✓ SQL paramétré : Doctrine QueryBuilder ou requêtes préparées
- ✓ JWT sécurisé : Algorithme RS256, expiration courte
- ✓ Logs sécurité : Traçabilité des événements critiques
CI/CD et Déploiement
- ✓ Scan SAST : Analyse statique du code (SonarQube, PHPStan)
- ✓ Scan DAST : Tests de pénétration automatisés (OWASP ZAP)
- ✓ Dependency check : Audit des dépendances (composer audit)
- ✓ Secret scanning : Détection de secrets dans le code (GitGuardian)
- ✓ Container registry privé : Images stockées en privé
- ✓ Signature d'images : Docker Content Trust activé
7. Automatisation avec CI/CD
# .github/workflows/security.yml
name: Security Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Security Checker
run: composer audit
- name: PHPStan Static Analysis
run: vendor/bin/phpstan analyse src --level=8
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
- name: OWASP ZAP Scan
uses: zaproxy/action-baseline@v0.10.0
with:
target: 'https://staging.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
deploy:
needs: security-scan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to production
run: |
echo "Deploying securely to production..."
# Vos commandes de déploiementConclusion
La sécurisation d'une application Symfony avec Docker nécessite une approche multicouche qui couvre l'infrastructure, le code applicatif, et les processus de déploiement. En 2026, les attaques sont de plus en plus sophistiquées, et il est crucial d'adopter une posture de sécurité proactive plutôt que réactive.
Les points clés à retenir sont :
- Defense in depth : Multipliez les couches de sécurité plutôt que de compter sur une seule mesure
- Principe du moindre privilège : Limitez les accès et permissions au strict nécessaire
- Automatisation : Intégrez les contrôles de sécurité dans votre pipeline CI/CD
- Surveillance continue : Loggez et monitorer les événements de sécurité
- Mise à jour régulière : Maintenez vos dépendances et images à jour
La sécurité n'est pas un état final mais un processus continu. Auditez régulièrement votre infrastructure, effectuez des tests de pénétration, et restez informé des nouvelles vulnérabilités et bonnes pratiques. Avec les configurations présentées dans cet article, vous disposez d'une base solide pour protéger votre application Symfony conteneurisée contre les menaces modernes.
N'oubliez pas que la sécurité est aussi forte que son maillon le plus faible : formez vos équipes, documentez vos processus, et cultivez une culture de la sécurité au sein de votre organisation.