La sécurité d'une application Symfony ne s'arrête pas à la validation des données d'entrée
Un code propre, des requêtes paramétrées, une gestion rigoureuse des permissions : tout cela est nécessaire. Pourtant, si le navigateur de vos utilisateurs ne reçoit pas les bonnes instructions au moment d'afficher une page, des vecteurs d'attaque entiers restent ouverts. Les en-têtes HTTP de sécurité comblent précisément cet angle mort : ils ne modifient pas votre logique applicative, mais ils changent radicalement la manière dont le navigateur traite vos réponses.
Ce que sont les en-têtes de sécurité et pourquoi ils comptent
Chaque réponse HTTP contient des métadonnées que le serveur envoie au navigateur : type de contenu, durée de mise en cache, cookies. Les en-têtes de sécurité font partie de ces métadonnées, mais leur rôle est différent : ils donnent au navigateur des règles de conduite sur ce qu'il est autorisé à charger, à exécuter ou à transmettre.
Les plus importants dans un contexte PHP et Symfony sont les suivants :
Content-Security-Policy(CSP) : définit les origines autorisées pour les scripts, styles, images et autres ressources. Une politique stricte bloque l'exécution de scripts injectés par un attaquant, même si une faille XSS existe dans le code.Strict-Transport-Security(HSTS) : force le navigateur à n'utiliser HTTPS pour une durée définie. Protège contre les attaques de type SSL stripping.X-Frame-Options: empêche votre page d'être intégrée dans une iframe sur un site tiers. Bloque le clickjacking.X-Content-Type-Options: interdit au navigateur de deviner le type MIME d'une réponse. Réduit le risque d'exécution de fichiers malveillants uploadés.Referrer-Policy: contrôle les informations transmises dans l'en-têteRefererlors d'une navigation sortante. Limite la fuite d'URL internes.Permissions-Policy: restreint l'accès aux API sensibles du navigateur (caméra, micro, géolocalisation) depuis votre domaine ou des tiers.
Aucun de ces en-têtes ne nécessite de réécrire votre application. Leur absence, en revanche, laisse le navigateur décider seul, souvent avec des comportements permissifs par défaut.
De tous ces en-têtes, la CSP est le seul qui demande un vrai travail d'intégration : elle doit connaître les origines de vos ressources, et surtout produire un nonce par requête pour autoriser vos scripts inline sans ouvrir la porte à ceux d'un attaquant. C'est exactement le problème que résout mulertech/csp-bundle.
Gérer la CSP avec mulertech/csp-bundle
Le bundle génère l'en-tête CSP à chaque réponse et met à disposition des nonces nommés, utilisables directement dans Twig. Installation :
composer require mulertech/csp-bundle
Aucune étape d'activation à prévoir : le paquet est de type symfony-bundle, et Flex enregistre automatiquement ce type de paquet dans config/bundles.php, y compris en l'absence de recette. Le fichier config/packages/mulertech_csp.yaml n'est pas créé pour autant — mais comme les défauts du bundle sont déjà sûrs, une politique s'applique dès l'installation. Ce fichier ne devient nécessaire que le jour où vous sortez de ces défauts.
Il embarque des valeurs par défaut sûres pour toutes les directives : default-src 'self', object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self', upgrade-insecure-requests. Vous ne déclarez donc que ce qui diffère de ces défauts.
Une configuration minimale dans config/packages/mulertech_csp.yaml :
mulertech_csp:
directives:
script-src:
- "'self'"
- "nonce(main)"
style-src:
- "'self'"
- "'unsafe-inline'"
La syntaxe nonce(handle) déclare un nonce nommé. Chaque handle produit une valeur cryptographiquement sûre de 256 bits, régénérée à chaque requête, et récupérable dans Twig via la fonction csp_nonce() :
<script nonce="{{ csp_nonce('main') }}">
// Your inline JavaScript
</script>
Cette régénération tient dans les deux modèles d'exécution. Sous PHP-FPM, chaque requête reconstruit le conteneur, le nonce est donc neuf par construction. En runtime persistant — FrankenPHP en mode worker, RoadRunner, Swoole — les services survivent d'une requête à la suivante : le générateur y est vidé entre deux requêtes par le tag kernel.reset, sans quoi le même nonce serait resservi, lisible dans la page précédente, et n'opposerait plus rien à un script injecté.
L'intérêt des handles nommés apparaît dès que plusieurs familles de scripts cohabitent. Un nonce dédié à l'analytics ne sert pas à autoriser vos propres scripts applicatifs, et inversement :
mulertech_csp:
directives:
script-src:
- "'self'"
- "nonce(main)"
- "nonce(analytics)"
<script nonce="{{ csp_nonce('analytics') }}">
// Analytics script
</script>
Les balises écrites à la main ne sont d'ailleurs qu'une partie du problème. AssetMapper produit lui aussi du JSON et un script inline via importmap(), et cette fonction accepte un tableau d'attributs en second argument : le nonce s'y transmet comme sur n'importe quelle autre balise.
{{ importmap('app', {nonce: csp_nonce('main')}) }}
Sans cette précaution, l'importmap est rejetée par la politique et l'ensemble du JavaScript applicatif tombe — un symptôme spectaculaire pour une cause discrète.
Le nonce est également injectable côté PHP, ce qui permet de le passer à un service qui construit du markup ou un en-tête de réponse :
<?php
declare(strict_types=1);
namespace App\Service;
use MulerTech\CspBundle\CspNonceGenerator;
/**
* Provides the CSP nonce to services building inline markup.
*
* @package App\Service
* @author Sébastien Muler
*/
final class ScriptRenderer
{
/**
* @param CspNonceGenerator $nonceGenerator
*/
public function __construct(
private readonly CspNonceGenerator $nonceGenerator,
) {
}
/**
* @return string
*/
public function getMainNonce(): string
{
return $this->nonceGenerator->getNonce('main');
}
}
Adapter la politique au contexte de la requête
Toutes les pages d'une application n'ont pas les mêmes besoins. Un back-office n'a aucune raison de charger les mêmes origines qu'une page publique embarquant un widget de paiement. Le bundle expose BuildCspHeaderEvent, déclenché avant l'écriture de l'en-tête :
<?php
declare(strict_types=1);
namespace App\EventListener;
use MulerTech\CspBundle\CspNonceGenerator;
use MulerTech\CspBundle\Event\BuildCspHeaderEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* Tightens the CSP policy on administration routes.
*
* @package App\EventListener
* @author Sébastien Muler
*/
#[AsEventListener(event: BuildCspHeaderEvent::NAME)]
final class AdminCspListener
{
/**
* @param CspNonceGenerator $nonceGenerator
*/
public function __construct(
private readonly CspNonceGenerator $nonceGenerator,
) {
}
/**
* @param BuildCspHeaderEvent $event
* @return void
*/
public function __invoke(BuildCspHeaderEvent $event): void
{
if (!str_starts_with($event->getRequest()->getPathInfo(), '/admin')) {
return;
}
$nonce = $this->nonceGenerator->getNonce('main');
$event->setHeaderValue("default-src 'self'; script-src 'self' 'nonce-{$nonce}'");
}
}
setHeaderValue() remplace l'en-tête entier, défauts du bundle compris : le nonce doit être réinjecté à la main dans la valeur de remplacement. L'oublier revient à retirer 'nonce-…' de script-src sur les routes ciblées, et donc à faire tomber l'importmap et tout le JavaScript applicatif de ces pages.
Pour le cas plus simple d'un CDN utilisé par l'ensemble des ressources, l'option always_add évite de répéter la même origine dans chaque directive. Elle est fusionnée partout, sauf dans les directives fixées à 'none' :
mulertech_csp:
always_add:
- "https://cdn.example.com"
directives:
object-src:
- "'none'" # always_add n'est pas fusionné ici
Les autres en-têtes : un listener sur la réponse
Le bundle se concentre sur la CSP. Les cinq autres en-têtes sont statiques et tiennent dans un listener sur kernel.response :
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Adds the static security headers not handled by the CSP bundle.
*
* @package App\EventListener
* @author Sébastien Muler
*/
#[AsEventListener(event: KernelEvents::RESPONSE)]
final class SecurityHeadersListener
{
/**
* @param ResponseEvent $event
* @return void
*/
public function __invoke(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$headers = $event->getResponse()->headers;
// Only meaningful over HTTPS, and preload is a long-term commitment
$headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Legacy fallback: frame-ancestors already covers modern browsers
$headers->set('X-Frame-Options', 'DENY');
$headers->set('X-Content-Type-Options', 'nosniff');
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
}
}
Deux points de vigilance. X-Frame-Options fait doublon avec la directive frame-ancestors déjà posée par le bundle : les navigateurs récents privilégient la seconde, l'en-tête ne sert plus que de repli pour les clients anciens. Quant à preload dans le HSTS, il engage votre domaine dans une liste embarquée par les navigateurs : à ne poser que lorsque tous les sous-domaines sont réellement servis en HTTPS, la sortie de liste étant longue.
Calibrer la CSP sans casser l'application
La CSP est l'en-tête le plus puissant, et le plus délicat à régler. Une directive trop restrictive casse les ressources légitimes (fonts Google, scripts analytics, widgets tiers) ; une directive trop permissive n'apporte qu'une protection symbolique.
La méthode recommandée pour les projets existants consiste à observer avant de bloquer. Le bundle bascule en mode rapport avec une seule ligne, ce qui envoie Content-Security-Policy-Report-Only à la place de Content-Security-Policy :
mulertech_csp:
report_only: true
report:
url: "https://report.example.com/csp"
Le navigateur signale alors les violations sans les bloquer. Sur un site à fort trafic, l'option chance limite le volume de rapports en n'activant le reporting que sur un pourcentage des requêtes :
mulertech_csp:
report:
url: "https://report.example.com/csp"
chance: 10 # 10 % des requêtes
Si vous préférez collecter les violations chez vous plutôt que chez un tiers, l'endpoint peut être une route Symfony :
mulertech_csp:
report:
route: "app_csp_report"
La séquence est alors : quelques jours en report_only, analyse des origines réellement utilisées, ajout de celles qui sont légitimes, puis passage à report_only: false.
Reste le cas des scripts inline, fréquent dans les templates Twig. C'est précisément là que les nonces nommés remplacent 'unsafe-inline' dans script-src : cette directive annule l'essentiel de la protection contre le XSS, puisqu'elle autorise indistinctement vos scripts et ceux qu'un attaquant parviendrait à injecter. Un nonce, régénéré à chaque requête, n'autorise que le markup que vous avez vous-même produit.
La configuration minimale donnée plus haut conserve pourtant 'unsafe-inline' dans style-src, et c'est un compromis assumé plutôt qu'un oubli : un style injecté ne s'exécute pas, sa nuisance se limite à la défiguration et à quelques techniques d'exfiltration par sélecteurs. S'en passer suppose de porter un nonce sur chaque balise <style> et de bannir tout attribut style, ce que la plupart des bases de code ne supportent pas sans réécriture. La protection utile, celle qui bloque le XSS, se joue sur script-src.
Vérifier et maintenir sa configuration
Après déploiement, plusieurs outils permettent de vérifier que les en-têtes sont correctement envoyés :
- securityheaders.com : attribue une note (A+ à F) et détaille les en-têtes manquants ou mal configurés.
- Les outils développeur du navigateur (onglet Réseau, détails d'une requête) permettent une vérification immédiate en local. La console y signale aussi chaque ressource bloquée par la CSP, avec la directive fautive.
- Dans un pipeline CI/CD, un simple
curl -I https://votre-domaine.comsuffit à valider la présence des en-têtes critiques après chaque déploiement.
Ces en-têtes doivent être revus à chaque intégration d'un nouveau service tiers : un widget de paiement, une bibliothèque d'analyse ou un outil de chat en ligne peut nécessiter d'élargir la CSP. Avec une configuration déclarative en YAML, cet ajustement devient une ligne de diff relue en pull request plutôt qu'une chaîne de caractères modifiée à la volée en production. Traiter la CSP comme du code applicatif à part entière, et non comme un détail d'infrastructure, évite d'accumuler des politiques trouées au fil du temps.
La même logique transposée à Laravel — middleware global, calibrage progressif de la CSP, nonces gérés par Vite — est détaillée dans cet article de Kriosa sur dev.to, quatorzième entrée d'une série complète sur la sécurité PHP et Laravel.
Conclusion
Les en-têtes HTTP de sécurité sont rares dans le sens où ils offrent un gain de protection élevé pour un coût d'implémentation faible. Un listener de quelques lignes couvre les cinq en-têtes statiques ; un bundle dédié couvre la CSP, ses nonces et son reporting sans code à maintenir. Le vrai travail est dans le réglage initial de la politique : il demande de la rigueur, mais il n'est pas complexe. Le résultat protège vos utilisateurs contre le clickjacking, le vol de cookies par injection XSS et plusieurs classes d'attaques navigateur qui restent invisibles jusqu'au jour où elles ne le sont plus.
mulertech/csp-bundle est publié sous licence MIT, compatible PHP 8.4+ et Symfony 6.4 à 8.x. Le code et la documentation complète sont disponibles sur GitHub.
Et votre site, il vaut quoi ?
Vitesse, accessibilité, référencement technique. Le rapport est écrit pour un dirigeant, pas pour un développeur. Gratuit, sans inscription.