HTTP Security Headers en PHP : guide CSP, HSTS, X-Frame-Options

Déployer HSTS, CSP et X-Frame-Options pour PrestaShop : où poser les headers (edge vs PHP), gérer nonces, rollout report-only et monitoring CSP.

Interface utilisateur illustrant les concepts de sécurité HTTP avec CSP et HSTS.

Table des matières :

  1. Choisir le bon point d’injection : PHP, serveur web, reverse proxy (et pourquoi ça compte)
  2. HSTS (Strict-Transport-Security) : forcer HTTPS durablement sans bloquer votre site
  3. X-Frame-Options vs CSP frame-ancestors : clickjacking, back-office et pages sensibles
  4. Content-Security-Policy (CSP) : la seule défense XSS “structurelle”, mais elle exige une discipline de rendu
  5. Implémentation en PHP (PrestaShop/Symfony), validation et monitoring en conditions réelles

Choisir le bon point d’injection : PHP, serveur web, reverse proxy (et pourquoi ça compte)

Poser des HTTP Security Headers en PHP est rarement le meilleur premier réflexe. La plupart des en-têtes de sécurité (HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy, etc.) sont stateless et devraient idéalement être définis au plus près du edge (Nginx/Apache, HAProxy, CDN), pour éviter les incohérences entre endpoints, réduire la dette applicative et garder une configuration auditable. Si vous avez déjà une brique de routage centralisée (API gateway / reverse proxy), c’est souvent le point le plus robuste pour uniformiser la surface d’attaque et gérer les exceptions par vhost ou par chemin (voir aussi l’approche « contrôle au gateway » dans l’article sur l’API Gateway : authentification, rate limiting et routage des microservices).

Pour choisir rapidement où poser quoi, le tableau suivant évite les erreurs fréquentes (ex. header défini sur le front mais oublié sur /api, ou un CDN qui renvoie une autre politique que le backend) :

Emplacement Avantages Limites En-têtes typiques
CDN / reverse proxy (edge) Cohérence sur tout le domaine, simple à auditer, pas couplé à l’app Moins flexible pour une politique dépendant du HTML (nonce CSP), risque de doublons si l’app en ajoute HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-Content-Type-Options
Serveur web (Nginx/Apache) Même bénéfice edge si vous n’avez pas de proxy, efficace sur pages d’erreur, redirections Difficile si multi-apps/chemins hétérogènes, exceptions par route plus verbeuses HSTS, XFO, headers généraux
PHP / framework Dynamique (nonce/hash), exceptions fines par contrôleur, utile sur routes spécifiques Risque d’incohérence entre apps, dépend de la chaîne de proxy (HTTPS mal détecté), complexifie le rendu/caching CSP (nonce/hash), exceptions par page

Il reste un cas où le PHP est pertinent : dès que l’en-tête dépend du rendu ou de l’état de la requête. Typiquement, une CSP (Content-Security-Policy) à base de nonce ou de hash (pour autoriser des scripts inline spécifiques) implique de générer une valeur cryptographiquement aléatoire par réponse et de l’injecter dans le HTML. C’est difficile à faire uniquement côté serveur web, sauf à accepter une CSP statique très permissive… ce qui rate l’objectif. En clair : HSTS et X-Frame-Options sont de bons candidats côté proxy/webserver ; CSP est souvent hybride (webserver pour le socle, PHP pour les nonces et les exceptions fines).

Deux scénarios terrain (typiques en e-commerce) illustrent le “bon point d’injection” :

  • Boutique derrière CDN + reverse proxy (ex. cache HTML, images sur sous-domaine, API sur /api) : vous voulez des headers identiques sur toutes les réponses (y compris 301/404). L’edge est le bon endroit pour HSTS/XFO/Referrer-Policy. Ensuite, vous ajoutez la CSP en PHP uniquement sur les pages HTML “dynamiques” si vous devez gérer des nonces.
  • Boutique multi-domaines / multi-marques : si chaque vhost a ses exceptions (scripts tiers différents, iframes autorisées pour un partenaire, etc.), le proxy permet un contrôle “par domaine” plus lisible que des conditions dispersées dans l’app.

Côté PrestaShop (8.x / 9.x), le cœur n’offre pas une stratégie « security headers » homogène : certaines pages front passent par des contrôleurs legacy, le back-office s’appuie sur Symfony, et des modules injectent encore du JS inline à la volée. Résultat : si vous activez une CSP stricte sans inventaire préalable, vous allez casser des morceaux (checkout, widgets, tags, paiement). Avant de toucher aux headers, faites au minimum le durcissement de base (TLS, redirections, règles serveur) décrit dans Sécurité PrestaShop : mises à jour, SSL et durcissement .htaccess, puis traitez les headers comme une couche supplémentaire.

Checklist rapide avant d’ajouter des headers “dans l’app” :

  • Le proxy/CDN ajoute-t-il déjà des en-têtes (sinon, vous risquez des doublons) ?
  • Les pages HTML sont-elles cachées (Varnish/CDN) ? Si oui, un nonce CSP par réponse va impacter le cache.
  • Avez-vous des sous-domaines historiques (images, assets, anciens outils) ? Ils conditionnent HSTS includeSubDomains.
  • Avez-vous une exigence “GEO / conformité” : par exemple, des logs de reporting CSP peuvent contenir URL, user-agent, parfois des fragments. En France/UE, vous devez traiter ça comme de la donnée potentiellement personnelle (politique de rétention, accès, minimisation).

HSTS (Strict-Transport-Security) : forcer HTTPS durablement sans bloquer votre site

HSTS est l’en-tête qui rend le downgrade HTTP quasiment caduc… à condition de ne pas se tromper. MDN résume correctement le mécanisme : “The HTTP Strict-Transport-Security response header (often abbreviated as HSTS) informs browsers that the site should only be accessed using HTTPS, and that any future attempts to access it using HTTP should automatically be converted to HTTPS.” (MDN, Strict-Transport-Security : MDN — Strict-Transport-Security). Concrètement, le navigateur mémorise une politique par domaine pendant max-age secondes.

Les pièges sont connus et rarement documentés dans les tutos “copier-coller” :

  • HSTS ne doit être envoyé que sur des réponses HTTPS (sinon certains navigateurs l’ignorent, d’autres comportements varient).
  • includeSubDomains est cassant si vous avez des sous-domaines encore en HTTP (ex. img. historique, old-admin. oublié, environnements “staging” exposés).
  • Le preload (preload) est encore plus engageant : une fois listé dans la HSTS Preload List (Chromium/Firefox/Safari s’en inspirent), revenir en arrière est lent et dépend d’un cycle de release navigateur.
  • Sur un site e-commerce, un oubli fréquent est le domaine d’assets (images, fonts, JS). Si vous avez static.example.com ou img.example.com, vérifiez qu’il supporte HTTPS avant includeSubDomains.

RFC 6797 formalise l’intention : “The Strict-Transport-Security header field indicates that the user agent should access the site only using secure transport.” (RFC 6797, §1 : RFC 6797).

Une stratégie de déploiement prudente (utile quand on hérite d’un historique de sous-domaines) :

  1. Semaine 1 : max-age=86400 (1 jour), sans includeSubDomains.
  2. Semaine 2–3 : max-age=2592000 (30 jours), toujours sans includeSubDomains si vous n’avez pas audité.
  3. Stabilisé : max-age=31536000 (1 an), puis seulement ensuite includeSubDomains si tous les sous-domaines sont propres.

Si (et seulement si) vous envisagez le preload, vérifiez les critères et la procédure sur le site officiel du projet Chromium : HSTS Preload (conditions typiques : HTTPS partout, redirection HTTP→HTTPS, max-age élevé, includeSubDomains, et présence du token preload).

Déploiement minimal (Nginx) en production, après validation que tout le trafic passe en HTTPS (301 propre, pas de mixed-content) :

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Le always est important : sans lui, certaines réponses (notamment des erreurs) peuvent sortir sans HSTS, ce qui crée des comportements difficiles à diagnostiquer.

Apache (module headers) :

Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

HAProxy (utile si vous terminez TLS ici) :

http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" if { ssl_fc }

En PHP, ne le faites que si vous ne pouvez pas toucher au serveur web, et en gardant le garde-fou HTTPS :

if (!headers_sent() && (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')) {
    header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}

Point d’attention “reverse proxy” : côté application, $_SERVER['HTTPS'] peut être faux si TLS est terminé avant (CDN/LB). Dans Symfony, Request::isSecure() dépend des trusted proxies / X-Forwarded-Proto. Tant que cette chaîne n’est pas fiable, préférez injecter HSTS côté proxy (où vous savez si la connexion entrante était TLS).

X-Frame-Options vs CSP frame-ancestors : clickjacking, back-office et pages sensibles

Le clickjacking reste un classique sur les back-offices et les pages à fort impact (panier, authentification, paiement). X-Frame-Options est l’ancien verrou : MDN rappelle sa vocation : “The X-Frame-Options HTTP response header can be used to indicate whether or not a browser should be allowed to render a page in a <frame>, <iframe>, <embed> or <object>.” (MDN, X-Frame-Options : MDN — X-Frame-Options). En pratique, vous n’avez que deux valeurs vraiment interopérables : DENY et SAMEORIGIN.

Le point technique important : X-Frame-Options est global, grossier, et ne permet pas une liste d’origines moderne. Pour ça, on préfère frame-ancestors dans CSP, qui supporte https://… et des patterns plus fins. Exemple strict :

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

Exemple “self” (site intégrable uniquement par lui-même) :

X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'

Pourquoi garder parfois les deux ? Parce que frame-ancestors est la directive moderne, mais X-Frame-Options reste un filet de sécurité pour certains clients/agents plus anciens ou des environnements où la CSP est partiellement neutralisée. L’objectif n’est pas la redondance “pour faire joli”, mais la couverture.

Sur PrestaShop, la théorie se heurte vite à des contraintes terrain : certains PSP ou widgets legacy ont historiquement utilisé des iframes (moins fréquent aujourd’hui, mais encore présent sur des intégrations). Si vous posez DENY partout, vous risquez de casser des pages embarquées ou des scripts de paiement selon votre stack. L’approche saine :

  • Back-office : DENY + frame-ancestors 'none' (le BO n’a généralement aucune bonne raison d’être iframé).
  • Front : SAMEORIGIN + frame-ancestors 'self' pour éviter les intégrations non voulues.
  • Exceptions : uniquement sur des routes identifiées et documentées (par exemple, une page d’authentification SSO ou un widget réellement conçu pour être embarqué).

Exemple concret de segmentation côté Nginx (logique, à adapter à votre arborescence PrestaShop et à vos vhosts) : vous durcissez le back-office sans impacter le front.

location /admin/ {
  add_header X-Frame-Options "DENY" always;
  add_header Content-Security-Policy "frame-ancestors 'none'" always;
}
location / {
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header Content-Security-Policy "frame-ancestors 'self'" always;
}

Si vous êtes dans une logique de durcissement global (WAF, journalisation, segmentation), XFO/CSP frame-ancestors n’est qu’une couche. Pour la partie back-office/API, l’article Sécurité PrestaShop : protéger API backoffice, WAF et journalisation SIEM couvre les contrôles complémentaires (auth, WAF, logs d’audit) qui évitent de “miser” uniquement sur des headers.

Content-Security-Policy (CSP) : la seule défense XSS “structurelle”, mais elle exige une discipline de rendu

CSP est l’en-tête qui force une hygiène d’exécution côté navigateur. La définition la plus opérationnelle reste celle de MDN : “Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross-Site Scripting (XSS) and data injection attacks.” (MDN, Content-Security-Policy : MDN — Content-Security-Policy). L’idée n’est pas “d’empêcher tous les XSS” (aucune policy ne compense un template vulnérable), mais de réduire drastiquement l’exploitabilité (sources limitées, inline interdit, object-src 'none', etc.).

Une CSP utile commence par un périmètre clair

Sur une boutique, vous avez généralement au moins 4 catégories de ressources :

  • Core : vos JS/CSS/images servis depuis votre domaine.
  • Tiers marketing : tags, pixels, A/B test, analytics.
  • Tiers transactionnel : paiement, anti-fraude, chat/CRM, avis.
  • Assets/CDN : fonts, images, bundles, parfois sur domaine séparé.

Sans inventaire, une CSP “au pif” finit soit en policy trop permissive (qui n’apporte presque rien), soit en régression visible (checkout/paiement). L’approche qui marche en e-commerce n’est pas le “big bang” mais le rollout en deux phases : d’abord CSP Report-Only, ensuite enforcement. Une CSP report-only vous permet de cartographier les violations sans bloquer la prod. Exemple :

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Pendant cette phase, vous inventoriez :

  • les scripts tiers réellement utilisés (tag manager, A/B test, paiement, chat, avis, etc.) ;
  • les inline scripts injectés par le thème et les modules ;
  • les endpoints d’API et de CDN nécessaires (connect-src, img-src, font-src) ;
  • les iframes réellement chargées (frame-src) : en e-commerce, c’est souvent là que se cachent les surprises.

Astuce de diagnostic : une fois la CSP en place (même en report-only), les navigateurs modernes donnent des erreurs explicites dans la console (directive violée, source bloquée, ressource concernée). C’est souvent plus actionnable que “ça ne marche plus” côté métier.

Le vrai verrou : script-src (et comment éviter ‘unsafe-inline’)

Le vrai verrou technique, c’est le script-src. Tant que votre front contient du inline JS non maîtrisé, vous serez tenté d’ajouter 'unsafe-inline' (qui annule une grosse partie de la protection). La sortie propre passe par :

  1. Déplacer les inline scripts vers des fichiers versionnés (idéal quand c’est possible).
  2. Autoriser l’inline au cas par cas via nonce ou hash.

Les nonces sont simples conceptuellement (une valeur aléatoire par réponse) mais impactent vos caches : si le HTML est servi depuis Varnish/CDN, vous ne pouvez pas injecter un nonce unique sans désactiver le cache HTML, ou sans stratégie ESI/fragmentation. Sur des boutiques très cache-friendly, les hashes (stables) sont parfois un meilleur compromis, tant que l’inline est réellement statique (sinon, chaque variation de contenu casse le hash).

Exemple de CSP “socle” raisonnable pour une boutique, à adapter après audit (ne copiez pas tel quel en prod sans vérifier vos sources réelles) :

default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
script-src 'self' 'nonce-{NONCE}';
connect-src 'self' https:;
upgrade-insecure-requests;

Note volontaire : style-src 'unsafe-inline' est souvent nécessaire sur PrestaShop à cause de patterns historiques (inline CSS, style tags, libs). Vous pouvez l’éliminer, mais ça implique un chantier front (thème + modules) non trivial. En pratique, il vaut mieux commencer par rendre script-src strict (le levier XSS majeur), puis traiter le CSS ensuite.

Deux directives souvent oubliées mais très utiles en e-commerce :

  • form-action : limite où les formulaires peuvent poster (utile contre certaines exfiltrations et détournements).
  • base-uri : déjà dans l’exemple, protège contre des attaques jouant sur la balise <base>.

Pour le reporting CSP, report-uri est historiquement répandu mais déprécié au profit de report-to/Reporting API. En 2026, le support est hétérogène selon navigateurs : si vous voulez de la couverture, vous finissez souvent par accepter les deux, et collecter côté serveur (Nginx/PHP) ou via un endpoint dédié. Référence utile : spécification W3C CSP (niveau 3) : W3C CSP3.

Point “GEO / conformité” souvent négligé : les rapports CSP contiennent des informations sur la navigation (URL, user-agent, directive, parfois l’URL de la ressource). Si vous les centralisez, traitez-les comme un flux de logs à gouverner (durée de conservation, accès restreint, minimisation), surtout si votre boutique cible des clients en France/UE.

Implémentation en PHP (PrestaShop/Symfony), validation et monitoring en conditions réelles

Sur PrestaShop 8/9 (avec une base Symfony côté back-office et, selon les pages, un mix legacy), la voie la plus propre pour poser des headers en PHP est un EventSubscriber Symfony sur kernel.response, packagé dans un module. Ça évite de patcher le core et centralise la logique. Si vous n’avez pas l’habitude du wiring DI, reprenez les bases sur Module PrestaShop 9 : structure, services et bonnes pratiques Symfony.

Exemple minimal (schématique) :

// src/EventSubscriber/SecurityHeadersSubscriber.php
namespace Vendor\Module\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;

final class SecurityHeadersSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return ['kernel.response' => 'onResponse'];
    }

    public function onResponse(ResponseEvent $event): void
    {
        $response = $event->getResponse();
        $request = $event->getRequest();

        // HSTS uniquement si HTTPS vu côté app (attention aux proxies, cf. trusted proxies)
        if ($request->isSecure()) {
            $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }

        // Fallback legacy + équivalent moderne dans CSP
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');

        // CSP statique (ou injectez un nonce provenant du request scope)
        $response->headers->set('Content-Security-Policy', "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'");

        // Bonus (hors titre mais utile)
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
    }
}

Pour une CSP à nonce, la mécanique minimale ressemble à ceci (principe, à adapter à votre templating) :

  • générer le nonce tôt (middleware/subscriber sur kernel.request ou service dédié),
  • le stocker dans le scope de requête (attribut request),
  • l’injecter dans la CSP et dans les tags <script nonce="...">.

Même sans montrer tout le wiring, l’idée clé est : le nonce doit être identique entre header et HTML pour une réponse donnée.

Les points qui font échouer les déploiements :

  1. Des headers déjà ajoutés par Nginx/CDN (vous en dupliquez et vous obtenez des valeurs incohérentes). Exemple classique : deux CSP différentes envoyées par deux couches → comportement imprévisible et debugging pénible.
  2. Une détection HTTPS fausse derrière proxy (si trusted_proxies / X-Forwarded-Proto n’est pas configuré, isSecure() ment). Avant de conditionner HSTS/Cookies Secure, validez ce point dans votre chaîne CDN/LB.
  3. Des exceptions par page non gérées (ex. routes d’upload, endpoints JSON, pages avec scripts tiers). Une bonne pratique est de limiter la CSP surtout aux réponses HTML (Content-Type: text/html) et d’éviter d’imposer une policy lourde sur des endpoints techniques qui n’en bénéficient pas.

Avant d’“accuser CSP”, vérifiez la chaîne complète : CDN → LB → reverse proxy → PHP-FPM. Si vous utilisez HAProxy, la logique d’ACL et de headers côté edge doit rester la référence (voir HAProxy 3.2 : déploiement systemd, configuration frontend/backend, ACL).

Validation : partez d’un test brut et reproductible.

curl -sI https://example.com/ | grep -iE 'strict-transport-security|content-security-policy|x-frame-options'

Complétez par des tests “par route”, parce que c’est souvent là que les divergences se cachent (home, catégorie, produit, panier, checkout, login, back-office). Exemple :

for path in / /panier /commande /connexion; do
  echo "== $path =="
  curl -sI "https://example.com$path" | grep -iE 'content-security-policy|x-frame-options'
done

Puis passez sur des outils d’audit externes (ils ne remplacent pas vos tests, ils révèlent surtout les oublis) : Mozilla Observatory et SecurityHeaders.com. Pour éviter la régression silencieuse (un module qui réintroduit inline JS, un nouveau domaine tiers, etc.), vous pouvez ajouter un check en CI/CD qui vérifie les headers sur un environnement de staging.

Enfin, la CSP devient vraiment utile quand elle est observée. Centralisez les rapports CSP (si vous activez le reporting) et mettez un seuil d’alerte sur les pics (souvent corrélés à une release ou à un tag tiers compromis). Si vous avez déjà un stack de supervision, branchez-le : l’article Surveillance PrestaShop : tableau de bord, seuils et réduction des fausses alertes donne une méthode pour éviter les alertes “bruit”, et vous pouvez exposer des métriques via Prometheus (par ex. compteur de violations CSP par route) dans la logique décrite par Prometheus sur Kubernetes OVHcloud : déployer kube-prometheus-stack avec Helm ou Netdata monitoring : surveiller AWS, Kubernetes, bases de données et serveurs web. Sans boucle de feedback (logs → métriques → décision), les HTTP security headers finissent souvent en “config figée” qui casse le jour où le front évolue.


À lire aussi