Skip to main content
Une illustration 3D futuriste montre un bouclier de sécurité vert lumineux avec une note "A+" proéminente affichée en son centre. Le bouclier est situé au milieu de baies de serveurs stylisées et lumineuses et de flux de code binaire. Les lettres "GO" et un logo Hugo sont incrustés dans le premier plan numérique. La scène entière a une esthétique cyber-sécurité bleu foncé et vert néon.

De F à A+ : Un guide des En-têtes de Sécurité Hugo & Netlify

8 min 1,705 words
Note

Archive Technique

Cet article discute de la version précédente de ce blog, qui était construit avec Hugo. Le site est maintenant propulsé par Astro. Bien que les concepts de sécurité explorés ici restent valides, les détails d’implémentation spécifiques liés à Hugo ne s’appliquent plus au site en ligne.

En plongeant dans les forums concernant Hugo (le générateur de site statique construit en Go que j’utilise pour ce blog) et en lisant la documentation officielle, j’ai régulièrement rencontré le sujet de la configuration des politiques de sécurité. Par conséquent, j’ai décidé d’auditer et de durcir la sécurité de mon blog. Dans ce post, je détaille le parcours qui m’a mené à élever cette sécurité à un niveau satisfaisant.

Avant d’explorer les détails, je dois clarifier que je ne suis pas un expert en sécurité. J’ai fait de mon mieux pour comprendre les bases, et j’espère que cet article vous sera utile.

Mesurer le Niveau de Sécurité Initial

Prérequis : vous devez avoir un site web déployé et disponible en ligne. L’outil que j’ai utilisé pour mesurer le score de sécurité de mon site est Security Headers by Probely. Entrez simplement l’adresse du site web que vous voulez scanner. Après quelques secondes, l’outil affiche une note (allant de F, symbolisant un niveau de sécurité terrible, à A+, le niveau le plus élevé). Vous pouvez aussi scanner d’autres sites web ; vous pourriez être surpris par les faibles niveaux de sécurité de certains sites bien connus !

Scan appliqué à spotify.com
Scan appliqué à spotify.com

J’ai lancé la même analyse sur mon blog et obtenu le résultat suivant :

Analyse initiale
Analyse initiale

J’ai rassemblé quelques informations utiles. D’abord, le score était assez bas (D). Cependant, il y avait un point positif : l’en-tête "Strict-Transport-Security" était déjà défini et valide. Sans aller trop loin dans les détails (voir cet article), cet en-tête force l’utilisation de TLS dans le navigateur web.

Maintenant que nous savons où nous en sommes, voyons comment ajouter les en-têtes manquants.

Configuration des En-têtes de Base

En regardant la page de documentation pour configurer le serveur, il y a une configuration de base pour l’environnement de développement à ajouter à config/development/server.toml :

[[headers]]
for = '/**'
[headers.values]
Content-Security-Policy = 'script-src localhost:1313'
Referrer-Policy = 'strict-origin-when-cross-origin'
X-Content-Type-Options = 'nosniff'
X-Frame-Options = 'DENY'
X-XSS-Protection = '1; mode=block'

Puisque ce fichier est dans un package “development”, il est destiné à un usage local. J’ai initialement prévu de créer un fichier par environnement. Malheureusement, Netlify ignore les en-têtes définis de cette manière durant le processus de build, peu importe si vous spécifiez l’environnement avec --environment=<l_environnement> ou placez le fichier server.toml dans configs/_default/.

J’ai par conséquent choisi de définir les en-têtes de production directement dans le fichier de configuration netlify.toml situé à la racine de mon projet. J’ai gardé le fichier server.toml pour itérer dans mon environnement de développement local. Je recommande cet article si vous voulez en apprendre plus sur l’ajout d’en-têtes à ce fichier.

Note : Il y a une autre façon de configurer ces en-têtes. J’ai testé avec succès la définition d’en-têtes dans un fichier static/_headers pour les sites hébergés sur Netlify (voir cette documentation).

Ajouter la Configuration à netlify.toml

Basé sur la “Configuration des En-têtes de Base”, la structure ressemble à ceci :

[[headers]]
for = '/**'
[headers.values]
...

[[headers]] démarre le bloc de configuration des en-têtes. for = '/**' indique que la configuration s’applique à tous les chemins sur le site web. Enfin, [headers.values] commence les définitions d’en-têtes spécifiques. Vous pouvez définir différents en-têtes pour différents chemins comme suit :

[[headers]]
for = '/**'
[headers.values]
...
                                               
/posts/**
[headers.values]
...

/articles/**
[headers.values]
...

Je vais maintenant détailler les différents en-têtes, leurs valeurs possibles, comment je les ai appliqués, et l’impact sur le classement de sécurité.

Permissions-Policy

Cet en-tête spécifie quelles fonctionnalités (comme la caméra, le microphone, ou la géolocalisation) peuvent être utilisées sur le site web. Plus de détails sont disponibles sur MDN Web Docs ici. Mon blog n’a besoin d’aucune de ces fonctionnalités. Par conséquent, j’assigne une allowlist vide () à chacune. À cette étape, mon fichier netlify.toml ressemble à ceci :

[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'

J’ai appliqué la mise à jour, redéployé le site, et lancé une nouvelle évaluation. Résultat :

Analyse après ajout de l'en-tête Permission-Policy
Analyse après ajout de l’en-tête Permission-Policy

Bien que j’aie toujours le même classement D, l’en-tête Permissions-Policy est devenu vert—excellente nouvelle ! Cela prouve que la configuration est active.

Vous pouvez aussi valider les en-têtes en utilisant les Google Chrome DevTools. Clic-droit > Inspecter, puis cliquez sur l’onglet “Réseau”. Rafraîchissez la page, trouvez la ligne correspondant à l’URL de votre site (type “Document”), et cliquez dessus pour voir les En-têtes actifs.

Google Chrome DevTools avec l'en-tête Permissions-Policy mis en évidence
Google Chrome DevTools avec l’en-tête Permissions-Policy mis en évidence

X-Frame-Options

Ces en-têtes empêchent la page d’être rendue à l’intérieur d’un <frame>, <iframe>, <embed>, ou <object>. Cela protège les visiteurs des attaques de clickjacking, où des objets invisibles sont superposés à des éléments légitimes pour voler des informations sensibles.

Il y a deux options valides : SAME-ORIGIN (permet le rendu seulement si la frame vient de la même origine) ou DENY (bloque le rendu peu importe l’origine). Plus de détails sont disponibles ici. Puisque je n’ai pas besoin d’embarquer mon site où que ce soit, je le règle sur DENY :

[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'

Après redéploiement, X-Frame-Options est devenu vert, et j’ai atteint un classement C.

Analyse après ajout de l'en-tête X-Frame-Options
Analyse après ajout de l’en-tête X-Frame-Options

Nous sommes sur la bonne voie !

X-XSS-Protection

Selon MDN Web Docs, X-XSS-Protection est un en-tête déprécié destiné à empêcher les attaques cross-site scripting. À moins que vous n’ayez besoin de supporter des navigateurs “legacy”, cet en-tête peut être omis en faveur d’une Content-Security-Policy (CSP) forte qui désactive le JavaScript inline. Comme vous allez le voir, configurer la CSP est une mission délicate…

X-Content-Type-Options

Cet en-tête empêche le navigateur de “deviner” (sniffing) le Content-Type d’une page demandée. Il a une valeur majeure : nosniff. Utiliser nosniff est hautement recommandé pour éviter les attaques de reniflage MIME (MIME sniffing). Si un attaquant envoie un fichier avec un type de contenu incorrect (ex. une image qui contient en fait un script), le navigateur pourrait l’exécuter si le sniffing est autorisé. Plus de détails ici.

J’ai ajouté cet en-tête avec la valeur nosniff :

[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'

Nouvelle évaluation :

Analyse après ajout de l'en-tête X-Content-Type_Options

Avec un classement B, nous nous rapprochons de la note maximale ! Au suivant : Referrer-Policy.

Referrer-Policy

Cet en-tête contrôle combien d’informations sur le “referrer” (la page d’où vient l’utilisateur) sont incluses avec les requêtes. Il détermine quelles données sont envoyées à la destination quand un utilisateur clique sur un lien sur votre site (ex. rien, l’URL de base, le chemin complet).

Pour mon blog, j’utilise la valeur par défaut : strict-origin-when-cross-origin. Cela envoie l’origine, le chemin et la chaîne de requête pour les requêtes de même origine. Pour les requêtes cross-origin, cela envoie seulement l’origine, et seulement si le niveau de sécurité du protocole est le même (HTTPS vers HTTPS).

[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'
Referrer-Policy = 'strict-origin-when-cross-origin'

Une nouvelle vérification m’a donné le résultat suivant :

Analyse après ajout de la Referrer-Policy
Analyse après ajout de la Referrer-Policy

Yay, c’est vert ! J’ai atteint le rang A ! Mais nous ne nous arrêterons pas là. Visons le A+ avec le boss final : Content-Security-Policy.

Content-Security-Policy (CSP)

Configurer cet en-tête finement peut être un cauchemar. Comprendre et configurer cela proprement a nécessité des heures de travail, durant lesquelles j’ai dû abandonner ou remplacer certains outils tiers. Il n’y a pas de configuration générique : une CSP rigoureuse différera d’un site web à l’autre, et même entre les environnements !

Je recommande d’utiliser hugo server localement pour vérifier les effets de vos changements. Utilisez Google Chrome DevTools, qui met en évidence les erreurs CSP et offre des suggestions.

Commençons avec les valeurs les plus restrictives possibles :

[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'
Referrer-Policy = 'strict-origin-when-cross-origin'
Content-Security-Policy = "default-src 'none' ; script-src 'none' ; script-src-elem 'none' ; connect-src 'none' ; 
img-src 'none' ; style-src 'none' ; style-src-elem 'none' ; base-uri 'none'; form-action 'none' ; font-src 'none' ; object-src 'none'"

Une Note sur l’Indentation

L’en-tête Content-Security-Policy contient beaucoup de directives (default-src, script-src, etc.). Pour le rendre lisible et maintenable, utilisez les chaînes multilignes en TOML :

Content-Security-Policy = """
  default-src 'none';
  script-src 'none';
  script-src-elem 'none';
  connect-src 'none';
  img-src 'none';
  style-src 'none';
  style-src-elem 'none';
  base-uri 'none';
  form-action 'none';
  font-src 'none';
  object-src 'none'
"""

Vous pouvez même aller plus loin en automatisant la génération des en-têtes. Je recommande cet article si vous êtes intéressé.

Après avoir ajouté cette configuration à netlify.toml et redéployé, j’ai lancé une nouvelle évaluation.

Analyse après ajout de la Content-Security-Policy
Analyse après ajout de la Content-Security-Policy

Oui ! Je l’ai fait ! A+. La note parfaite !

Cependant, en regardant mon site, j’ai remarqué que son apparence avait changé “légèrement”.

Mon blog, sans l'en-tête CSP
Mon blog, sans l’en-tête CSP

Le même blog, après avoir ajouté l'en-tête CSP
Le même blog, après avoir ajouté l’en-tête CSP

La console était inondée de logs d’erreur.

La console Google Chrome affiche une erreur pour une Content Security Policy avec tous les champs réglés sur 'none'
La console Google Chrome affiche une erreur pour une Content Security Policy avec tous les champs réglés sur ‘none’

Nous devons maintenant résoudre ces erreurs une par une.

In http://localhost:1313/p/hugo-netlify-setup-security-headers/
Refused to load the script 'http://localhost:1313/livereload.js?mindelay=10&v=2&port=1313&path=livereload'
because it violates the following Content Security Policy directive: "script-src-elem 'none'".

Cette erreur bloque le script car script-src-elem interdit l’exécution. Ce script spécifique est pour le live reload de Hugo. Puisque ceci n’est utilisé que localement, j’ajoute localhost:1313/livereload.js seulement dans development/config.toml.

Voici des cas communs que vous rencontrerez probablement :

Cas 1 : erreurs style-src-elem

Refused to load the stylesheet 'http://localhost:1313/scss/path_to_file.css'
because it violates the following Content Security Policy directive: "style-src-elem 'none'".

Solution : Remplacez 'none' par 'self'. Cela permet les feuilles de style de la même origine. N’utilisez pas d’URLs absolues comme http://localhost:1313... dans la config de production, car elles ne marcheront pas une fois déployées.

Cas 2 : erreurs img-src

Refused to load the image 'http://localhost:1313/favicon.png'
because it violates the following Content Security Policy directive: "img-src 'none'".

Solution : Similaire aux styles, remplacez 'none' par 'self' dans la section img-src.

Cas 3 : Scripts Externes

Refused to load the script 'https://domain/path_to_file.js'
because it violates the following Content Security Policy directive: "script-src-elem http://localhost:1313".

Solution : Ajoutez le domaine à la liste autorisée (ex. script-src-elem 'self' https://domain/path_to_file.js).

Cas 4 : Scripts Inline (La Partie Délicate)

Refused to execute inline script...
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution.

Trois solutions existent :

  1. Unsafe-inline : Souvent affectueusement appelé le “chemin de la moindre résistance”. Cela autorise globalement n’importe quel script inline. Je le déconseille fortement. Lisez cet article pour comprendre pourquoi.

  2. Hashes : Vous ajoutez le hash spécifique fourni dans le log d’erreur à votre CSP. Cela marche bien pour les scripts statiques mais échoue pour le contenu dynamique (comme les sections de commentaires qui changent par page).

  3. Nonces : Idéalement adapté pour le contenu dynamique. Vous générez une chaîne aléatoire (nonce) et l’ajoutez à la balise <script>. J’ai créé un script Go pour aider à en générer, disponible ici.

Note : Les Gists GitHub et les shortcodes Hugo reposent souvent sur des styles/scripts inline, les rendant difficiles à utiliser sans unsafe-inline. Je suis passé de Disqus (commentaires) à une solution auto-hébergée nommée Remark42 pour maintenir une CSP stricte.

Pour des tests plus poussés, je recommande csp-evaluator.withgoogle.com.

Le Mot de la Fin

Pas si facile, hein ?

J’ai poussé cet exercice assez loin, visant une sécurité maximale. Cependant, la sécurité est un spectre. Selon la criticité de votre projet, vous pouvez ajuster le curseur. Il est essentiel de garantir un certain niveau de confiance pour vos visiteurs, mais vous pouvez choisir de relâcher certaines contraintes (particulièrement concernant la CSP) pour équilibrer sécurité, fonctionnalité et maintenabilité.

Je vise personnellement ce point d’équilibre : un haut niveau de sécurité avec un investissement d’énergie raisonnable.

Sources