Tous les articles

Google Tag Manager sous une CSP stricte dans Next.js

CentralCSP Team ·

Dernière mise à jour:

Vous pouvez faire tourner Google Tag Manager (GTM) dans une app Next.js App Router sous une politique de sécurité du contenu (CSP) stricte sans ajouter aucun domaine Google à votre directive de script. Une CSP est un header de réponse HTTP qui indique au navigateur quels scripts et autres ressources une page peut charger et exécuter. L'approche stricte est un nonce par requête que Next.js génère, copié sur le script d'amorçage GTM, avec 'strict-dynamic' qui propage cette confiance à gtm.js et à chaque tag que GTM charge.

Ce billet est le volet Next.js de deux articles à lire d'abord. Comment mettre en place un nonce CSP dans Next.js couvre le branchement de base du nonce dans proxy.ts, et CSP pour Google Analytics et Tag Manager couvre le mécanisme indépendant du framework : pourquoi c'est le nonce sur l'amorçage qui compte, et où les hosts Google ont leur place. Cet article relie les deux et souligne les pièges propres à Next.js.

Le flux en une minute

  1. Générez un nonce par requête dans proxy.ts et placez-le dans le header CSP sur la requête et sur la réponse.
  2. Lisez ce nonce dans le layout et mettez-le sur le script d'amorçage GTM.
  3. 'strict-dynamic' fait confiance à l'amorçage, puis à gtm.js et aux tags que GTM injecte, parce qu'ils descendent d'un script porteur de nonce.
  4. Gardez les hosts Google hors de script-src. Ils vont dans connect-src, img-src et frame-src.

Les trois premières étapes sont de la pure plomberie Next.js. La quatrième est celle qu'on oublie.

Le nonce vient du proxy

Dans une app Next.js App Router, vous générez un nouveau nonce à chaque requête dans proxy.ts (la fonction proxy exportée, renommée depuis middleware.ts dans Next.js 16 et versions ultérieures) avec Web Crypto, vous placez la CSP sur les headers de la requête transmise pour que Next.js puisse lire le nonce, et vous la replacez sur la réponse pour que le navigateur l'applique. Comment mettre en place un nonce CSP dans Next.js détaille cette configuration de bout en bout, voici donc seulement la partie qui compte pour GTM :

proxy.ts
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'` // [!code highlight]
// set csp on requestHeaders and on the response, plus 'x-nonce' for your components

Next.js applique ce nonce aux scripts qu'il rend pour vous. La seule chose qu'il ne fait pas automatiquement, c'est mettre le nonce sur un amorçage tiers que vous écrivez à la main, comme GTM. C'est l'étape qui vous revient.

Lisez le nonce et mettez-le sur l'amorçage GTM

GTM fonctionne en injectant une balise <script> pour gtm.js, qui injecte ensuite d'autres scripts pour chaque tag de votre conteneur. Sous une politique stricte, le navigateur n'exécutera l'amorçage que s'il porte le nonce correspondant. Une fois que c'est le cas, 'strict-dynamic' fait confiance à l'amorçage et à tout ce qu'il charge en aval, donc gtm.js et les tags tournent sans aucun host Google dans script-src.

Lisez le nonce depuis les headers de la requête dans votre layout racine (un Server Component), puis rendez avec cette valeur l'amorçage Google qui propage le nonce :

app/layout.tsx
import { headers } from 'next/headers'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? ''
  return (
    <html lang="en">
      <head>
        <script
          nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `(function(w,d,s,l,i){w[l]=w[l]||[];
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0], j=d.createElement(s),
dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;
var n=d.querySelector('[nonce]');
n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

Deux détails rendent ce code compatible avec une CSP stricte. L'attribut nonce={nonce} est ce qui permet à 'strict-dynamic' de faire confiance à l'amorçage inline. Les lignes d.querySelector('[nonce]') copient le nonce sur la balise gtm.js injectée, ce qui maintient le nonce en circulation pour les tags qui le cherchent. Le nonce ici doit être la valeur exacte que le proxy a mise dans le header CSP, c'est pourquoi vous le lisez depuis x-nonce plutôt que d'en générer un nouveau. Cet amorçage est le snippet officiel de Google qui gère le nonce ; la seule partie propre à Next.js est la lecture du nonce depuis headers().

Lire headers() fait basculer la route en rendu dynamique, ce qu'un nonce par requête exige de toute façon. Un nonce réutilisé sur une réponse mise en cache statiquement met la politique en échec.

Le piège d'ordonnancement de next/script

Il est tentant de charger GTM avec next/script au lieu d'une balise inline, et next/script accepte bien une prop nonce. Le hic, c'est l'ordre et le runtime.

next/script avec la stratégie par défaut afterInteractive injecte la balise depuis un client component après l'hydratation. Le nonce que vous passez doit être la valeur par requête venue du proxy, vous devez donc quand même le lire depuis headers() dans un Server Component et le faire descendre en prop. Si vous générez ou codez en dur un nonce côté client, il ne correspondra pas au header et le script sera bloqué. L'amorçage inline dans le layout évite complètement le problème, puisqu'il est rendu côté serveur avec le nonce de la requête déjà en portée.

Si vous utilisez bien next/script, mettez strategy="afterInteractive" (pas beforeInteractive, qui s'exécute avant que le balisage porteur de nonce soit en place de façon fiable pour les tiers) et passez le nonce lu côté serveur :

app/layout.tsx
import Script from 'next/script'
import { headers } from 'next/headers'

const nonce = (await headers()).get('x-nonce') ?? ''

<Script id="gtm" nonce={nonce} strategy="afterInteractive">{`/* GTM bootstrap here */`}</Script>

Pour la plupart des configurations, l'amorçage inline rendu côté serveur est plus simple et moins sujet aux erreurs. Choisissez-en un, pas les deux, sinon vous chargez le conteneur deux fois.

Où vont réellement les hosts Google

'strict-dynamic' ne régit que les scripts. GTM et les tags qu'il charge envoient aussi des beacons d'analytics, récupèrent des pixels et ouvrent une frame de debug, et aucun de ces éléments n'est un chargement de script. Ces requêtes relèvent d'autres directives, qui exigent toujours que les hosts Google soient listés. C'est là que la configuration Next.js trébuche, comme n'importe quelle autre stack : le script-src strict est correct, et pourtant la page casse tant que ces directives ne sont pas remplies.

  • connect-src pour les beacons et fetches d'analytics. Utilisez des wildcards pour couvrir les endpoints de collecte régionaux : https://*.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com https://www.google.com.
  • img-src pour les requêtes de pixels : https://*.google-analytics.com https://*.googletagmanager.com.
  • frame-src pour la frame Preview et Debug de GTM : https://www.googletagmanager.com.

Une politique stricte complète depuis le proxy, avec script-src ne portant que le nonce et 'strict-dynamic' :

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{SERVER-GENERATED-NONCE}' 'strict-dynamic';
  connect-src 'self' https://*.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com https://www.google.com;
  img-src 'self' https://*.google-analytics.com https://*.googletagmanager.com;
  frame-src https://www.googletagmanager.com;
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint

Les tags qui déclenchent des pixels de publicité ou de conversion (Google Ads, Floodlight) atteignent plus de hosts. N'ajoutez que ceux que votre conteneur déclenche vraiment, que le mode Report-Only vous révélera, plutôt que d'allowlister tout le lot par avance.

Testez en Report-Only d'abord

Un conteneur GTM change dès que quelqu'un ajoute un tag, et chaque tag peut atteindre un host que votre politique n'a pas autorisé. Déployez la politique d'abord sur le header Content-Security-Policy-Report-Only en changeant le nom du header dans le proxy et en ajoutant un header Reporting-Endpoints :

Reporting-Endpoints: csp-endpoint="https://<Endpoint-ID>.report.centralcsp.com"

Le navigateur ne bloque rien et signale tout ce qu'il aurait bloqué, donc vous voyez le host exact dont un nouveau tag a besoin avant qu'il ne puisse casser l'analytics en production.

CentralCSP collecte ces reports Report-Only, les regroupe par directive et origine, et inventorie les scripts qui tournent sur chaque page, pour que vous voyiez précisément quel host Google un tag réclame et quels scripts GTM charge avant de passer en application. Démarrez un essai gratuit, pointez-y un header Report-Only depuis votre app Next.js, et regardez les reports arriver du trafic réel.

Questions fréquentes

Dois-je ajouter googletagmanager.com à script-src dans Next.js ?

Non. Avec un nonce par requête et 'strict-dynamic' dans script-src, le navigateur ignore les allowlists de hosts pour les scripts. Vous mettez le nonce sur l'amorçage GTM, et 'strict-dynamic' fait confiance à gtm.js et aux tags qu'il charge.

Comment Next.js passe-t-il le nonce à GTM ?

Pas automatiquement. Next.js met un nonce sur ses propres scripts, mais un amorçage tiers que vous écrivez, c'est à vous de le gérer. Lisez le nonce depuis headers() (le header x-nonce que le proxy a posé) dans votre layout et mettez-le sur le script d'amorçage GTM.

Dois-je charger GTM avec next/script ou un script inline ?

Les deux marchent sous une CSP stricte, mais vous devez passer le nonce par requête venu du proxy dans les deux cas. L'amorçage inline rendu côté serveur est plus simple parce que le nonce est déjà en portée ; next/script exige de faire descendre le nonce en prop et d'utiliser la stratégie afterInteractive. N'utilisez pas les deux, sinon le conteneur se charge deux fois.

Pourquoi GTM casse-t-il encore avec un script-src correct ?

Parce que GTM et ses tags envoient aussi des beacons, récupèrent des pixels et ouvrent une frame de debug, que script-src ne régit pas. Ajoutez les hosts Google à connect-src, img-src et frame-src.

À retenir

Faire tourner GTM sous une CSP stricte dans Next.js, c'est la configuration de base du nonce plus une étape supplémentaire. Générez le nonce dans proxy.ts, lisez-le depuis headers() dans le layout, et mettez-le sur l'amorçage GTM pour que 'strict-dynamic' fasse confiance à gtm.js et à chaque tag. Gardez les hosts Google hors de script-src, où ils sont ignorés, et listez-les dans connect-src, img-src et frame-src, où les requêtes se produisent réellement. Testez en Report-Only d'abord pour qu'un nouveau tag ne casse jamais l'analytics en production.

Pour aller plus loin : le guide CSP de Next.js et les recommandations GTM et CSP de Google.

Sources

Articles liés