ADRADR-0047 · Pages publiques cacheables par le CDN et CSP à nonce

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.

LectureOù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 explicitechaque page publiquetoutes les pages traduites
<JsonLd> : headers() pour le nonce + getLocale()src/lib/seo/json-ld.tsxtoute page qui porte du JSON-LD
<LanguageBanner> : cookies() + headers() (Accept-Language)(marketing)/layout.tsxtout (marketing)
<FoundingAnnouncement> : cookies()(marketing)/layout.tsxtout (marketing)
getSession()(minimal)/extension/installcette page, par construction
searchParams + force-dynamic(minimal)/extension/uninstalled, intelligence/inspect/[tool], intelligence/radarces 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 :

SurfaceBloquée par (hors layout racine)Candidate ?
/compare, /compare/[slug]<LanguageBanner>, <FoundingAnnouncement>, <JsonLd>, getTranslationsoui : contenu pur, aucune donnée de compte
/intelligence/stores/[domain], /intelligence/weekly/** (P6/P7)idem + chargeurs Postgresoui, après /compare
/intelligence/radar, /intelligence/inspect/[tool]revalidate = 0 / force-dynamic / searchParamsnon : filtres par requête
/extension/installsessionnon : affiche l'état connecté
/extension/uninstalledsearchParams (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.

  1. 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.
  2. experimental.sri ne couvre que les scripts externes (required-scripts.js : integrity sur le script d'amorçage et les preinit). L'exemple de la doc Next (script-src 'self' sans unsafe-inline) ne dit pas comment les scripts inline de (1) s'exécutent ; la même page de doc donne script-src 'self' 'unsafe-inline' pour le cas « sans nonce ». À ne pas prendre pour une recette sûre.
  3. 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

OptionVerdict
(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 à hashLe 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

  1. 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 de unsafe-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.
  2. 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.
  3. 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.tsx et 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 demande experimental.globalNotFound pour le 404 global : un chantier à conflits avec tous les agents, à faire seul, un jour sans autre lot ouvert sur src/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).
  4. Quand ces deux conditions sont réunies, on pilote /compare/** puis on inscrit les entrées dans STATIC_PUBLIC_GROUP_ENTRIES et MUST_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).
  • revalidate reste inerte sur six routes ; le commentaire de src/app/layout.tsx le 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)

  1. Groupe src/app/(static)/ avec son propre layout.tsx racine : <html lang> fixé par le segment, aucun cookies(), headers(), getLocale().
  2. Segment [locale] + generateStaticParams sur les six langues ; setRequestLocale(locale) en tête de layout et de chaque page ; getTranslations({ locale, namespace }) et getFormatter({ locale }) avec la langue explicite (l'analyseur laisse passer ces formes, et seulement elles).
  3. Proxy : /fr/compare est 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 gardent buildCsp() inchangé.
  4. Remplacer dans le groupe <LanguageBanner> et <FoundingAnnouncement> par des composants client (lecture du cookie dans un useEffect), et <JsonLd> par une variante qui prend la langue en prop et ne lit pas headers() (selon le résultat du test navigateur sur ld+json).
  5. export const revalidate = <n> sur chaque page, generateStaticParams pour les segments dynamiques ; invalider par revalidateTag aux écritures.
  6. Inscrire les fichiers d'entrée dans STATIC_PUBLIC_GROUP_ENTRIES et les routes dans MUST_BE_STATIC, puis next build et node 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 (--report pour le tableau, sans échec). Non branché dans vercel-build.mjs tant que MUST_BE_STATIC est 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.