Panneau vert lumineux de sortie de secours illuminé dans l'obscurité — l'échappatoire officielle

Photo par Esdras Jaimes sur Pexels

Symfony tue silencieusement votre cache HTTP dès qu'une session démarre

J'ai découvert à mes dépens que Symfony possède un listener de sécurité qui écrase votre directive Cache-Control avec private, max-age=0, must-revalidate dès qu'une session PHP est démarrée pendant la requête.

Plus important, il existe une échappatoire officielle lorsqu'on sait que la réponse peut malgré tout être mise en cache.

Si vous vous êtes déjà battu avec ce header :

Cache-Control: max-age=0, must-revalidate, private, s-maxage=86400

alors que votre code demande clairement une réponse publique, cet article est pour vous.

Le symptôme

Avec API Platform 3.4 et cette configuration, on définit les headers de cache d'une ressource :

#[ApiResource(
    cacheHeaders: [
        'etag' => true,
        'max_age' => 86400,
        'shared_max_age' => 86400,
        'vary' => ['Accept'],
    ]
)]
class Article
{
}

On s'attend à quelque chose comme :

Cache-Control: max-age=86400, public, s-maxage=86400
ETag: "abc123"

Mais dans certaines situations, on observe :

Cache-Control: max-age=0, must-revalidate, private, s-maxage=86400

Dans cette situation, même si le s-maxage est présent dans le header, il devient inutile pour un cache partagé : la réponse est passée en private et le cache partagé passera à côté de la réponse sans tenir compte de la durée définie dans s-maxage.

Pas d'erreur, pas de log : votre configuration API Platform était correcte, elle a simplement été modifiée plus tard dans le cycle HTTP.

Comment je suis tombé là-dessus : des tests Behat sur le cache

Je ne suis pas tombé là-dessus en lisant le code source par curiosité un dimanche après-midi. Je suis tombé là-dessus en écrivant des tests Behat sur le cache HTTP de l'API.

J'écrivais donc des scénarios Behat pour couvrir le comportement de cache de nos endpoints API, dans le genre :

Scenario: Les articles publiés sont mis en cache publiquement
  Given je suis authentifié en tant qu'utilisateur
  When j'envoie une requête "GET" vers "/api/articles"
  Then la réponse a le statut 200
  And le header de réponse "cache-control" contient "public"
  And le header de réponse "cache-control" contient "max-age=86400"
  And le header de réponse "cache-control" contient "s-maxage=86400"

À l'exécution du test, il a échoué. Pas de public dans le header, mais un max-age=0, must-revalidate, private, s-maxage=86400 à la place.

C'est là que j'ai commencé à regarder ce qui pouvait modifier la réponse après qu'API Platform ait correctement configuré ses headers.

Le coupable : AbstractSessionListener avec une priorité à -1000

Le responsable est le listener Symfony\Component\HttpKernel\EventListener\AbstractSessionListener. Son rôle ne se limite pas à sauvegarder la session : quand une session est démarrée pendant une requête, Symfony transforme par défaut la réponse en réponse privée non cachable, une mesure de sécurité appréciable pour éviter de mettre en cache et de redistribuer par erreur des données privées propres à un utilisateur à travers une erreur de configuration du cache HTTP.

Deux détails expliquent pourquoi ça surprend autant.

Il passe très tard. Dans Symfony 6.4+, le listener s'abonne à kernel.response avec une priorité de -1000, explicitement pour s'exécuter parmi les derniers listeners de réponse après API Platform, après vos propres subscribers qui n'ont que rarement une priorité aussi basse de paramétrée je présume 😅.

Il regarde si la session a été utilisée, pas juste si elle existe. Ce n'est pas $request->hasSession(). Le code réel :

if ($autoCacheControl) { // cette condition va nous être utile plus tard (`true` par défaut)

    $maxAge = $response->headers->hasCacheControlDirective('public')
        ? 0
        : (int) $response->getMaxAge();

    $response
        ->setExpires(new \DateTimeImmutable('+'.$maxAge.' seconds'))
        ->setPrivate()
        ->setMaxAge($maxAge)
        ->headers->addCacheControlDirective('must-revalidate');
}

Avant même d'atteindre if ($autoCacheControl), AbstractSessionListener::onKernelResponse() enchaîne plusieurs garde-fous — chacun peut court-circuiter avec un return sans toucher au Cache-Control :

  1. Requête principale uniquementif (!$event->isMainRequest() || (!$container->has('initialized_session') && !$request->hasSession())) return; : les sous-requêtes sont ignorées.
  2. Header interne dépilé tôt$autoCacheControl = !$response->headers->has(self::NO_AUTO_CACHE_CONTROL_HEADER) puis remove() systématique, même si la suite sortira.
  3. Session attachée ?if (!$request->hasSession(true)) return; (le true cherche aussi dans les attributs, pas seulement la pile).
  4. Si isStarted()save() + cookies — sauvegarde anticipée (verrous, fastcgi_finish_request, régénération d'ID) et gestion du Set-Cookie : clearCookie si session vide et cookie présent, setCookie si nouvel ID et session non vide.
  5. Usage réel ?if ($session instanceof Session ? 0 === $session->getUsageIndex() : !$session->isStarted()) return; : une session qui existe mais n'a jamais été lue/écrite ne déclenche pas le passage en private.

Ce n'est qu'après ces sorties que le bloc s'exécute — maxAge vaut 0 si la réponse était public sinon getMaxAge(), puis setExpires(), setPrivate(), setMaxAge($maxAge) et must-revalidate.

Fichier : src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php6.4 sur GitHub.

Pourquoi Symfony fait ça ?

Une session est généralement synonyme de données dépendantes de l'utilisateur présentement connecté. Si une réponse GET /api/me pour l'utilisateur A devenait publiquement cachable, un utilisateur B pourrait recevoir les données de A depuis le cache partagé (comme Varnish par exemple).

Symfony adopte donc un comportement conservateur par défaut : session utilisée → réponse privée.

Le simple fait d'avoir un header Authorization: Bearer ... ne signifie pas que Symfony a démarré une session. Le déclencheur réel de AbstractSessionListener, c'est l'usage effectif de la session quelque part dans la requête, pas la présence d'un token d'authentification.

Donc même si votre pare-feu est configuré avec l'option stateless: true, il est possible que votre code, à un moment donné, utilise la session utilisateur pour des raisons tout à fait légitimes, ce qui déclenche le listener.

La vraie question à se poser est : une session est-elle effectivement utilisée pendant cette requête via votre code, un bundle, un listener, un contrôleur ?

Pour le savoir, cherchez les accès qui déclenchent effectivement la session :

$request->getSession()->start()
$request->getSession()->set(...)
$request->getSession()->get(...)
$request->getSession()->getFlashBag()

L'échappatoire : le header magique Symfony-Session-NoAutoCacheControl

Vous vous souvenez de if ($autoCacheControl) { ? C'est lui notre sauveur en la circonstance.

Parce qu'un échappatoire a été prévu pour contourner ce comportement dans le cas où l'on souhaite explicitement dire à Symfony :

OK, je sais que tu veux me protéger mais je sais ce que je fais alors oublie ça 5 minutes tu veux ?

Et ça passe par la vérification de la présence de la constante NO_AUTO_CACHE_CONTROL_HEADER dans les headers de la réponse :

$autoCacheControl = !$response->headers->has(
    self::NO_AUTO_CACHE_CONTROL_HEADER
);

La constante vaut Symfony-Session-NoAutoCacheControl.

Si votre Response porte ce header, le listener n'applique pas son private automatique, puis le supprime lui-même avant d'envoyer la réponse au client :

$response->headers->remove(self::NO_AUTO_CACHE_CONTROL_HEADER);

C'est un signal interne au serveur, pas un header de Response destiné à être transmis côté client et ne doit jamais l'être.

Un hack ? Non — c'est documenté

Jusqu'à Symfony 7.0, toute la classe AbstractSessionListener était @internal, donc PHPStan/Psalm hurlaient si vous utilisiez AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER.

La PR #53057[HttpKernel] Move @internal from AbstractSessionListener class to its methods and properties — a retiré @internal de la classe (gardé sur méthodes/props), rétroportée en 6.4, pour rendre la constante officiellement utilisable. La doc Symfony 7.1 l'affiche désormais explicitement :

use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;

$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');

Source : symfony.com/doc/current/http_cache.html#http-caching-and-user-sessions

Comment l'utiliser

Si vous contrôlez la Response :

use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;

$response->headers->set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    'true'
);

Dans une application API Platform, un subscriber kernel.response scope proprement le comportement :

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
use Symfony\Component\HttpKernel\KernelEvents;

final class BypassSessionCacheSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            // AbstractSessionListener = -1000, on doit passer avant
            KernelEvents::RESPONSE => ['onKernelResponse', -100],
        ];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        // Scoper par chemin — jamais globalement
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $event->getResponse()->headers->set(
            AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
            'true'
        );
    }
}

-100 n'a rien de magique : il est simplement supérieur à -1000, donc ce subscriber s'exécute avant AbstractSessionListener.

Le piège à éviter absolument

Ne posez pas ce header globalement sur toute réponse GET :

// À ne pas faire sans réfléchir au contenu de la réponse
if ($request->isMethodSafe()) {
    $response->headers->set(
        AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
        'true'
    );
}

GET /api/me, GET /api/cart ou GET /api/orders ne doivent pas devenir publiquement cachables simplement parce qu'elles utilisent GET.

La bonne question : cette réponse est-elle identique pour plusieurs utilisateurs ?

Dans mon cas, ça l'était. Mais si ce n'est pas le cas, ne contournez pas cette sécurité de Symfony.

De plus, il est préférable de whitelister les chemins qui ont droit de contourner cette sécurité pour éviter tout problème de fuite de données lié au cache.

TL;DR

#[ApiResource(cacheHeaders: [...])]
        → API Platform génère une réponse cachable
        → kernel.response
        → AbstractSessionListener (-1000)
        → session utilisée ? oui
        → Cache-Control devient private

Symfony fait ça volontairement pour éviter qu'une réponse liée à une session finisse accidentellement dans un cache partagé.

Si vous savez avec certitude que la réponse peut être cachée malgré l'usage de la session :

$response->headers->set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    'true'
);

posé sur la Response, jamais sur la Request, puis supprimé automatiquement par le listener avant l'envoi.

Et si une leçon est à retenir en plus du header lui-même : testez vos headers de cache comme n'importe quel autre comportement observable de votre API.


Commentaires