ADR-0047 — Pages publiques cacheables par le CDN : ce que la CSP à nonce permet, et ce qu'elle interdit
Tranche backlog/app-shell/0365. Garde-fous : src/test/public-static-contract.test.ts, src/test/isr-is-disabled-by-the-nonce.test.ts. Outils : scripts/check-static-routes.mjs, scripts/lib/request-reads.mjs.
Statut
Accepté · 2026-10-01
Piliers : app-shell, security-identity, growth-web
Titre court d’origine : Pages publiques cacheables par le CDN et CSP à nonce
Tranche backlog/app-shell/0365. Garde-fous : src/test/public-static-contract.test.ts,
src/test/isr-is-disabled-by-the-nonce.test.ts. Outils : scripts/check-static-routes.mjs,
scripts/lib/request-reads.mjs.
Contexte
Aucune route de l'application n'est statique ni ISR. export const revalidate
ne fait rien (six déclarations inertes, KNOWN_INERT), et P6 (annuaires
indexés, fiches boutiques) et P7 (rapport hebdomadaire) comptent sur du HTML
que le CDN peut servir.
Ce qui rend une route dynamique aujourd'hui
Next 16 rend une route par requête dès qu'UN composant serveur de son arbre
appelle une API de requête. Sans cacheComponents ni PPR (ni l'un ni l'autre
n'est activé, et la doc Next dit PPR incompatible avec les nonces), il n'existe
pas de « dynamique dans un Suspense » : un seul appel suffit.
| Lecture | Où | Portée |
|---|---|---|
getLocale() -> cookies() / headers() (src/i18n/request.ts) | src/app/layout.tsx corps ET generateMetadata() | toutes les routes |
(await headers()).get("x-csp-nonce") | src/app/layout.tsx (GTM, 3 JSON-LD, file Datafast, <Script>) | toutes les routes |
getTranslations() / getFormatter() sans locale explicite | chaque page publique | toutes les pages traduites |
<JsonLd> : headers() pour le nonce + getLocale() | src/lib/seo/json-ld.tsx | toute page qui porte du JSON-LD |
<LanguageBanner> : cookies() + headers() (Accept-Language) | (marketing)/layout.tsx | tout (marketing) |
<FoundingAnnouncement> : cookies() | (marketing)/layout.tsx | tout (marketing) |
getSession() | (minimal)/extension/install | cette page, par construction |
searchParams + force-dynamic | (minimal)/extension/uninstalled, intelligence/inspect/[tool], intelligence/radar | ces pages, par construction |
Relevé par node scripts/lib/request-reads.mjs sur le graphe d'imports serveur
de chaque route (approximation : l'autorité est le manifeste du build, voir
plus bas). Conséquences pour les surfaces visées :
| Surface | Bloquée par (hors layout racine) | Candidate ? |
|---|---|---|
/compare, /compare/[slug] | <LanguageBanner>, <FoundingAnnouncement>, <JsonLd>, getTranslations | oui : contenu pur, aucune donnée de compte |
/intelligence/stores/[domain], /intelligence/weekly/** (P6/P7) | idem + chargeurs Postgres | oui, après /compare |
/intelligence/radar, /intelligence/inspect/[tool] | revalidate = 0 / force-dynamic / searchParams | non : filtres par requête |
/extension/install | session | non : affiche l'état connecté |
/extension/uninstalled | searchParams (locale de l'URL de désinstallation) | non |
Le routage de langue ajoute un second piège (ADR 0038)
/fr/compare est réécrit par src/proxy.ts vers /compare avec la langue
dans l'en-tête x-boostecom-locale (LOCALE_HEADER). Une page ISR est clé par
son chemin résolu : /compare et /fr/compare partageraient une entrée de
cache, et l'en-tête qui les distingue n'est pas dans la clé. Une route
statique doit donc porter la langue dans un SEGMENT (/_static/[locale]/compare),
vers lequel le proxy réécrit, pas dans un en-tête.
Ce qui a été mesuré (et qui change l'arbitrage de 0365)
Les trois faits ci-dessous sont lus dans node_modules/next (Next 16.2.11) et
épinglés par public-static-contract.test.ts : une montée de version qui les
change fait échouer le test et renvoie ici.
- Les scripts inline de Next ne portent qu'un nonce. Chaque document App
Router inline
self.__next_f.push(<payload de la page>)(server/app-render/use-flight-response.js:<script nonce="…">ou<script>, jamais de hash ni d'intégrité). Le contenu change avec la page et avec chaque régénération ISR. Un'sha256-…'écrit dans un en-tête au build ne peut pas le couvrir. experimental.srine couvre que les scripts externes (required-scripts.js:integritysur le script d'amorçage et lespreinit). L'exemple de la doc Next (script-src 'self'sansunsafe-inline) ne dit pas comment les scripts inline de (1) s'exécutent ; la même page de doc donnescript-src 'self' 'unsafe-inline'pour le cas « sans nonce ». À ne pas prendre pour une recette sûre.- Next lit le nonce dans l'en-tête CSP de la REQUÊTE, au rendu
(
app-render.js:headers['content-security-policy']). Un document généré au build n'a ni requête ni nonce.
Donc l'option (b) du backlog (« hacher les scripts inline et sortir le nonce »)
est correcte pour NOS quatre scripts constants (ORGANIZATION_LD, WEBSITE_LD,
SOFTWARE_APP_LD, datafast-queue) et impossible pour ceux de Next.
« Hacher » ne suffit pas.
Non mesuré, à faire sur preview (une heure, protocole de 0365) : si Chrome,
Safari et Firefox appliquent script-src aux blocs type="application/ld+json".
La spec HTML les exempte (le type est jugé avant le contrôle CSP), le commentaire
de json-ld.tsx affirme le contraire sans source. Cela décide si <JsonLd>
peut cesser de lire headers(), pas de l'impasse ci-dessus.
Options évaluées
| Option | Verdict |
|---|---|
(a) Hash via experimental.sri + CSP de groupe dans next.config headers() | Écartée. Voir (2) : n'autorise pas les scripts inline de Next. Reste expérimental, et le build ici est Turbopack, dont le support n'est pas démontré. |
(b) Layout racine séparé sans headers()/cookies() + CSP à hash | Le layout séparé est nécessaire (voir « Préalables »), mais ne suffit pas : même impasse que (a) pour les scripts inline de Next. |
(c) 'strict-dynamic' + hashes par build | Écartée : les hashes changent à chaque régénération ISR, donc ISR impossible ; sans ISR, seulement du SSG redéployé, et il faudrait écrire les en-têtes après le build (Build Output API). |
| (d) Tamponnage du nonce à la périphérie : le proxy lit le HTML ISR en cache et y pose un nonce frais | Écartée pour ce lot. Seule voie qui garde strict-dynamic + nonce frais sur du HTML mis en cache, mais : code de sécurité sur mesure (regex sur le HTML, sinon un <script> injecté recevrait le nonce), une exécution de fonction par vue (économise Postgres, pas le CDN), non testable sans navigateur. À ne spiker que si le trafic le justifie. |
(e) Cache CDN de la réponse dynamique (s-maxage) avec son nonce | Écartée : un nonce partagé pendant un TTL est lisible par quiconque charge la page. C'est un affaiblissement de la CSP de ces routes. |
(f) Cache applicatif des chargeurs (unstable_cache, option (a) de 0365) | Retenue pour maintenant. Zéro surface CSP, fonctionne dans l'arbre actuel, supprime le coût Postgres qui est le vrai coût des surfaces crawlées. Ne donne pas de hit CDN. |
Décision
- Aucune route n'est rendue statique dans ce lot et la CSP ne bouge pas.
buildCsp()reste le seul constructeur : nonce par requête,strict-dynamic, pas deunsafe-inline,report-to csp-endpoint. Le test refuse un second constructeur « hash seul » tant qu'il n'existe pas de moyen d'autoriser les scripts inline de Next. - P6 et P7 avancent avec l'option (f) : le HTML reste rendu par requête, les chargeurs sont mis en cache (recette ci-dessous). Les pages se servent d'un cache chaud, pas de Postgres.
- L'ISR/CDN du HTML reste la cible, et dépend de deux préalables, tous
deux hors de ce lot :
- Racines de layout multiples. Un layout racine qui lit la requête rend
tout ce qu'il couvre dynamique. Il faut supprimer
src/app/layout.tsxet donner à chaque groupe de premier niveau son propre<html>(un composant partagé pour le groupe dynamique, un autre sans lecture de requête pour le groupe statique). Cela déplace/,not-found,error,oauth/sous un groupe et demandeexperimental.globalNotFoundpour le 404 global : un chantier à conflits avec tous les agents, à faire seul, un jour sans autre lot ouvert sursrc/app. - Un moyen d'autoriser les scripts inline de Next sans nonce de requête :
l'option (d), ou une évolution de Next/Vercel (hash des scripts inline,
nonce compatible
cacheComponents).
- Racines de layout multiples. Un layout racine qui lit la requête rend
tout ce qu'il couvre dynamique. Il faut supprimer
- Quand ces deux conditions sont réunies, on pilote
/compare/**puis on inscrit les entrées dansSTATIC_PUBLIC_GROUP_ENTRIESetMUST_BE_STATIC(scripts/lib/static-routes.mjs). Le test et le script de build deviennent alors la garde.
Conséquences
- Le HTML public coûte toujours une exécution de fonction par vue. On accepte : la facture qui compte (Postgres) est traitée par (f).
revalidatereste inerte sur six routes ; le commentaire desrc/app/layout.tsxle dit et pointe ici.- Dette : la décision d'owner de 0365 reste ouverte pour l'option (d). Signal de réouverture : trafic anonyme crawlé en continu qui se lit sur la facture Vercel Functions, ou une version de Next qui change l'un des trois faits.
Recette de migration d'un groupe public (quand les préalables sont levés)
- Groupe
src/app/(static)/avec son proprelayout.tsxracine :<html lang>fixé par le segment, aucuncookies(),headers(),getLocale(). - Segment
[locale]+generateStaticParamssur les six langues ;setRequestLocale(locale)en tête de layout et de chaque page ;getTranslations({ locale, namespace })etgetFormatter({ locale })avec la langue explicite (l'analyseur laisse passer ces formes, et seulement elles). - Proxy :
/fr/compareest réécrit vers/_static/fr/compare, pas vers/compare+ en-tête./compare(anglais) vers/_static/en/compare. La CSP de ces chemins est celle du mécanisme choisi en 3 ci-dessus ; les autres chemins gardentbuildCsp()inchangé. - Remplacer dans le groupe
<LanguageBanner>et<FoundingAnnouncement>par des composants client (lecture du cookie dans unuseEffect), et<JsonLd>par une variante qui prend la langue en prop et ne lit pasheaders()(selon le résultat du test navigateur surld+json). export const revalidate = <n>sur chaque page,generateStaticParamspour les segments dynamiques ; invalider parrevalidateTagaux écritures.- Inscrire les fichiers d'entrée dans
STATIC_PUBLIC_GROUP_ENTRIESet les routes dansMUST_BE_STATIC, puisnext buildetnode scripts/check-static-routes.mjs(code 0 requis).
Recette immédiate : option (f) pour un chargeur public
import { unstable_cache } from "next/cache"
export const loadDirectory = (niche: string, locale: string) =>
unstable_cache(() => queryDirectory(niche), ["directory", niche, locale], {
revalidate: 3600,
tags: [`directory:${niche}`],
})()
La clé porte tout ce qui change le résultat (langue, filtres) ; l'écriture qui
change la donnée appelle revalidateTag. Une fonction cachée ne lit jamais
cookies()/headers() : la donnée est publique par construction (champs payants
filtrés par field-gate/paywall-gate AVANT d'entrer dans le cache, jamais après).
Mesuré en production (2026-10-02)
Sur /compare servi par le déploiement de production : 106 balises <script>
sur 107 portent l'attribut nonce (les scripts /_next/static/chunks/* et les
blocs inline de Next compris), la CSP est celle de buildCsp() (nonce par
requête, strict-dynamic, pas de unsafe-inline), et la réponse est
private, no-cache, no-store avec x-vercel-cache: MISS. Le doute de la
section précédente (Next lit-il le nonce dans l'en-tête de requête, que le
proxy ne transmet pas ?) est donc levé : Next récupère le nonce par un autre
chemin et les scripts s'exécutent. Il ne reste à mesurer sur preview que le
comportement des navigateurs face à type="application/ld+json".
Comment c'est appliqué
src/test/public-static-contract.test.ts: les trois faits sur Next, le contrat de la CSP dynamique, l'analyseur et le classifieur de manifeste (sur fixtures, donc capables d'échouer), et le registre des groupes statiques.src/test/isr-is-disabled-by-the-nonce.test.ts: inchangé, tient le layout racine et la liste inerte.scripts/check-static-routes.mjs: lit.next/prerender-manifest.json(--reportpour le tableau, sans échec). Non branché dansvercel-build.mjstant queMUST_BE_STATICest vide ; à brancher avec la première inscription.
À vérifier sur une preview (non testable sans déploiement)
H=https://<preview>.vercel.app
# 1. Aujourd'hui tout est dynamique : attendu MISS/absent, jamais HIT sur du HTML
curl -sI $H/compare | grep -iE 'x-vercel-cache|x-nextjs-cache|cache-control'
# 2. La CSP est celle de buildCsp() : nonce + strict-dynamic, pas d'unsafe-inline
curl -sI $H/compare | grep -i '^content-security-policy' | tr ';' '
' | grep script-src
# 3. Les scripts de Next portent-ils le nonce ? (voir ci-dessous)
curl -s $H/compare | grep -o '<script[^>]*>' | head -8
Le point 3 vérifie un fait que ce lot n'a pas pu établir : Next lit le nonce
dans l'en-tête CSP de la REQUÊTE (fait 3), alors que src/proxy.ts
(buildSecurityResponse) ne transmet que x-csp-nonce en en-tête de requête,
pas Content-Security-Policy. Si les <script src="/_next/…"> et les
<script>self.__next_f… n'ont pas d'attribut nonce, la CSP les bloque
(strict-dynamic ignore 'self') et c'est un défaut distinct à ouvrir, que la
doc Next règle en posant l'en-tête sur la requête. Ne pas le corriger sans cette
mesure.