Nodefony 10.0.0-beta.6
Framework Node.js fullstack — HTTP & WebSocket, même contexte

En-têtes de sécurité — le contrat passé au navigateur

stable@nodefony/securitymis à jour 2026-07-19

Ton serveur ne contrôle pas le navigateur de tes visiteurs — il ne peut que lui donner des ordres, et ces ordres sont des en-têtes HTTP. Une douzaine de lignes ferment des classes entières d’attaques : XSS, clickjacking, sniffing MIME, fuite d’URL, downgrade HTTPS. Nodefony les pose en deux couches, une seule autorité par en-tête : le socle transport (@nodefony/http, dès l’entrée brute — couvre aussi les fichiers statiques et les erreurs) et la couche applicative (@nodefony/security, dans le pipeline — CSP, Referrer-Policy, isolation cross-origin). Ancré sur SecurityHeaders (securityHeaders.ts#SecurityHeaders) et Firewall.applySecurityHeaders() (firewall.ts#Firewall.applySecurityHeaders).

📍 Documentation › Sécurité › En-têtes de sécurité

🧠 Le modèle mental — deux couches, deux moments#

Un en-tête de sécurité n’a de valeur que s’il est sur toutes les réponses. Le piège classique n’est pas d’en oublier un : c’est de le poser de façon inégale — présent sur les routes de contrôleur, absent sur un fichier statique ou une page d’erreur 404, c’est-à-dire exactement là où un attaquant dépose son contenu.

D’où le découpage : ce qui doit couvrir tout ce qui sort du process est posé au plus tôt ; ce qui dépend de la réponse applicative (le CSP, qui doit connaître le nonce, la route et le type de la réponse) est posé au moment où la réponse écrit ses en-têtes : le pare-feu, armé au routage, choisit alors sa politique sur le Content-Type.

Une seule source par en-tête : @nodefony/security ne ré-émet jamais les trois en-têtes transport — c’est écrit noir sur blanc dans le contrat de la couche applicative (ISecurityHeadersOptions, securityHeaders.ts#ISecurityHeadersOptions). Pas de double émission, donc pas de valeurs contradictoires sur la même réponse.

📖 Lexique#

Terme Développé — et ce que ça veut dire
CSP Content-Security-Policy : liste blanche des sources autorisées (scripts, styles, images…). Première barrière anti-XSS.
XSS Cross-Site Scripting : un attaquant fait exécuter son JavaScript dans la page de ta victime, avec ses cookies.
Nonce number used once : jeton aléatoire régénéré à chaque requête, qui autorise un <script> inline précis et lui seul.
Clickjacking Ton site est chargé en <iframe> transparente au-dessus d’un piège : la victime croit cliquer ailleurs, elle clique chez toi.
MIME sniffing Le navigateur ignore le Content-Type et devine le type d’un fichier — un .txt uploadé peut finir exécuté comme script.
HSTS HTTP Strict-Transport-Security (RFC 6797) : le navigateur mémorise « ce domaine, c’est HTTPS uniquement ».
Referrer En-tête que le navigateur envoie au site suivant pour dire d’où l’on vient — donc une fuite d’URL potentielle.
COOP Cross-Origin-Opener-Policy : coupe le lien entre ta page et les fenêtres d’une autre origine qu’elle ouvre ou qui l’ont ouverte (window.opener). Lu sur un document seulement.
COEP Cross-Origin-Embedder-Policy : ta page (ou ton worker) refuse toute ressource tierce qui n’a pas dit « tu peux m’inclure ». Lu sur un document ou un script de worker.
CORP Cross-Origin-Resource-Policy : posé sur une ressource (image, script, JSON), dit quelles origines ont le droit de l’inclure. C’est la réponse que COEP attend.
Isolation cross-origin COOP same-origin + COEP require-corp : la page est seule dans son processus (crossOriginIsolated), condition de SharedArrayBuffer et des horloges fines.
OAC Origin-Agent-Cluster : demande au navigateur d’isoler l’origine dans son propre processus/heap.
Permissions-Policy Coupe l’accès aux API sensibles du navigateur (caméra, micro, géolocalisation) pour la page et ses iframes.
Fragment CSP Directives additionnelles déclarées par un module (directive → sources), fusionnées dans le CSP de base.
Downgrade Un attaquant réseau force la connexion en HTTP clair pour la lire ou la modifier.

Qu’est-ce que c’est ? — un panneau d’instructions collé sur chaque réponse#

Imagine que tu envoies un colis. Le contenu, c’est ton HTML. Les en-têtes de sécurité, c’est l’étiquette collée dessus : « ne pas ouvrir avec un autre outil que celui-ci », « ne pas transporter dans un autre camion », « interdiction de recopier l’adresse de l’expéditeur ». Le transporteur — le navigateur — les respecte. Sans étiquette, il improvise, et improviser c’est exactement ce qu’un attaquant attend.

Chaque en-tête ferme une faille concrète :

Le détail de chaque en-tête — menace, valeur par défaut, compromis — est dans le catalogue plus bas.

La vision Nodefony — pré-calculé au boot, quasi gratuit par requête#

Un framework qui recalcule ses en-têtes à chaque requête paie ce confort en allocations. Nodefony fait l’inverse : tout ce qui est constant est calculé une fois au démarrage.

Le second parti pris est la séparation d’autorité décrite plus haut : un seul émetteur par en-tête, donc un comportement prévisible et testable — le banc live vérifie les deux couches sur la même réponse (security-headers.test.ts).

❗ Important

@nodefony/security est optionnel, pas le socle. Une app Nodefony sans module security émet quand même nosniff, X-Frame-Options et HSTS : c’est du secure-by-default. Ce que tu perds sans security, c’est le CSP, la Referrer-Policy et l’isolation cross-origin.

🚀 Démarrage rapide#

Point de départ : une app générée par nodefony create app. Les en-têtes sont déjà actifs — ce que tu écris ci-dessous, ce sont tes écarts au défaut.

1. Déclarer la politique dans nodefony.config.ts#

// nodefony.config.ts — l'app n'écrit QUE ses écarts ; le reste prend le défaut du framework.
import { defineConfig, use } from "nodefony";

export default defineConfig(() => ({
  modules: [
    "@nodefony/http",
    "@nodefony/framework",
    use("@nodefony/security", {
      headers: {
        // `{{nonce}}` est substitué par un jeton FRAIS à chaque requête (cf plus bas).
        csp:
          "default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
          "style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'; " +
          "img-src 'self' data:; " +
          "object-src 'none'; base-uri 'self'; form-action 'self'",
        cspNonces: true,
        // Ne fuiter que l'origine, et rien vers un site en clair.
        referrerPolicy: "strict-origin-when-cross-origin",
        // Isolation cross-origin : ABSENTE par défaut, on l'active explicitement.
        coop: "same-origin",
        corp: "same-origin",
        permissionsPolicy: "camera=(), microphone=(), geolocation=()",
      },
    }),
  ],
}));

Les clés sont typées et auto-complétées : le module augmente le registre NodefonyModuleConfig du core (index.ts#nodefony), donc use("@nodefony/security", …) propose les clés et les valeurs d’enum (referrerPolicy, coop, corp…). Une valeur hors enum casse le boot, pas la production.

2. Ce qu’on observe#

# Une PAGE : socle transport + couche applicative complète, sur la MÊME réponse.
curl -sI http://localhost:5151/nodefony/test/html

# HTTP/1.1 200 OK
# X-Content-Type-Options: nosniff              ← transport (@nodefony/http)
# X-Frame-Options: DENY                        ← transport (@nodefony/http)
# Content-Type: text/html; charset=utf-8
# Content-Security-Policy: default-src 'self'; script-src 'nonce-vZ9…' 'strict-dynamic' 'self'; …
# Referrer-Policy: strict-origin-when-cross-origin
# Cross-Origin-Opener-Policy: same-origin
# Cross-Origin-Resource-Policy: same-origin
# Permissions-Policy: camera=(), microphone=(), geolocation=()

# Une réponse d'API : la politique courte, et seulement ce qu'un JSON fait lire.
curl -sI http://localhost:5151/nodefony/test/index

# Content-Type: application/json
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
# Referrer-Policy: strict-origin-when-cross-origin
# Cross-Origin-Resource-Policy: same-origin
# Le nonce change à CHAQUE requête — deux appels, deux jetons.
curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
# nonce-2Qk1r0h8…      (≠)
# nonce-Xa7pLd3f…

# Une URL inexistante : le socle transport est TOUJOURS là (l'applicatif ne s'exécute pas).
curl -sI http://localhost:5151/nodefony/test/__inexistant__ | grep -i x-content-type
# X-Content-Type-Options: nosniff

3. Un besoin ponctuel : élargir le CSP d’UNE route#

Tu dois embarquer une iframe YouTube sur une seule page. Élargir le CSP global serait une faute : tu ouvrirais l’ensemble du site. @Csp déclare l’écart à l’échelle de l’action.

// nodefony/controllers/EmbedController.ts — complet, compile tel quel.
import { controller, Controller, Get, Csp } from "@nodefony/framework";

@controller("/embed")
class EmbedController extends Controller {
  // `frame-src` n'existe QUE sur cette route ; le reste du site garde le CSP strict.
  @Csp({ "frame-src": ["https://www.youtube.com"] })
  @Get("/video")
  video() {
    return this.renderJson({ embed: true });
  }
}

export default EmbedController;
curl -sI http://localhost:5151/embed/video | grep -i content-security-policy
# … ; frame-src https://www.youtube.com      ← ajouté ICI seulement
curl -sI http://localhost:5151/nodefony/test/index | grep -c youtube
# 0                                          ← isolation prouvée (security-headers.test.ts)

🛡️ Le catalogue des en-têtes#

Choisir en cinq secondes — puis le détail dans les cartes.

En-tête Ce qu’il bloque Défaut Nodefony Qui l’émet
Content-Security-Policy XSS, injection de source politique « secure-but-usable » + nonce security (pipeline)
X-Frame-Options Clickjacking DENY http (transport)
X-Content-Type-Options MIME sniffing nosniff http (transport)
Strict-Transport-Security Downgrade HTTPS → HTTP max-age=31536000; includeSubDomains, TLS http (transport)
Referrer-Policy Fuite d’URL vers des tiers no-referrer security (pipeline)
Cross-Origin-Opener-Policy Attaques par fenêtre ouvrante absent (opt-in) security (pipeline)
Cross-Origin-Embedder-Policy Chargement de ressources non signées absent (opt-in) security (pipeline)
Cross-Origin-Resource-Policy Inclusion de tes assets par un tiers absent (opt-in) security (pipeline)
Origin-Agent-Cluster Partage de heap entre origines absent (opt-in) security (pipeline)
Permissions-Policy Accès caméra/micro/géoloc absent (opt-in) security (pipeline)
Content-Security-Policyla liste blanche des sources#

La menace : n’importe quelle entrée non échappée (commentaire, nom d’utilisateur, paramètre réfléchi) devient un vecteur d’exécution de code. Le CSP est le filet quand l’échappement a raté.

Le défaut Nodefony est délibérément « secure-but-usable » (config.ts#headersSchema) : script-src est strict — 'nonce-{{nonce}}' 'strict-dynamic' 'self', ce qui est la vraie défense XSS ; style-src exige le même nonce des <style> (voir plus bas). Le reste couvre les besoins réels d’une app moderne (images data:/blob:, workers, fetch/WS same-origin) et ajoute les durcissements gratuits : object-src 'none', base-uri 'self', form-action 'self', upgrade-insecure-requests.

Aucun hôte tiers dans le défaut. img-src vaut 'self' data: blob: : les photos de profil d’un fournisseur de connexion ne sont autorisées que s’il est configuré — chaque fournisseur porte l’hôte de ses avatars dans son enregistrement (oauthProviderRegistry.ts#registerOAuthProvider, option imageSources), et la connexion sociale le déclare au démarrage. Studio autorise https://www.gravatar.com sur ses seules pages (@Csp de StudioController.ts#StudioController). Une application qui affiche d’autres images tierces les ajoute par headers.cspSources (ci-dessous), sans réécrire la politique.

'strict-dynamic' : seul le nonce autorise une balise. Un navigateur CSP3 ignore alors 'self' et les hôtes ('self' ne reste que pour les navigateurs CSP2). Sans lui, tout fichier JavaScript posé sur ton origine — un téléversement, une réponse mal typée — s’exécutait dès qu’une injection écrivait <script src="/uploads/x.js">. En retour, un <script> sans nonce — en ligne ou src, même servi par ton origine — ne s’exécute plus. Dans une vue rendue par renderView, le framework le signe pour toi (ci-dessous) ; dans du HTML écrit à la main, écris <script nonce="${this.context?.cspNonce}" src="…">. Ce que ce script charge ensuite (un import(), un script qu’il crée) hérite de sa confiance : un chargeur tiers signé (paiement, carte) charge ses propres scripts sans que tu déclares leurs hôtes.

Deux cas où il n’y a pas de nonce, et où 'strict-dynamic' part avec lui pour ne pas bloquer toute la page :

  • une page servie telle quelle — un .html de public/, une réponse relayée par le proxy inverse : aucun gabarit ne l’a écrite. Elle reçoit la politique sans nonce ni 'strict-dynamic', et 'self' décide, comme avant. C’est sans perte : un fichier sert les mêmes octets à tous, il n’a pas de point d’injection côté serveur ;
  • cspNonces: false : le nonce est retiré de la politique, 'strict-dynamic' aussi — sauf si la directive garde un hash ('sha256-…'), qui continue d’autoriser ses scripts.

Une politique que tu écris toi-même sans {{nonce}} n’est jamais retouchée.

Styles : le même nonce, et rien à faire dans une application générée. Le défaut écrit style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'. En présence d’un nonce, le navigateur ignore 'unsafe-inline' : un <style> injecté dans ta page — qui peut exfiltrer un jeton par des sélecteurs d’attribut, sans une ligne de JavaScript — est bloqué. Un attribut style="…" reste permis : il ne porte pas de sélecteur. Ce qui est déjà en place :

  • les feuilles en <link rel="stylesheet"> de ton origine passent par 'self' ; les styles que React ou Vue écrivent par le CSSOM (element.style) ne sont pas gouvernés par la politique ;
  • chaque document rendu par le framework annonce son nonce par <meta property="csp-nonce" nonce="…"> (cspNonce.ts#cspNonceMetaTag). Le client de Vite y signe les styles du rechargement à chaud ; l’entrée Angular générée le donne à CSP_NONCE ;
  • un <style> que ton code crée à l’exécution : adoptStyles(css) (nodefony/client) le pose en feuille construite, que la politique ne gouverne pas ; une bibliothèque qui veut un nonce le lit par readCspNonce() (Mantine : getStyleNonce, Emotion : createCache({ nonce })) ;
  • dans une vue Eta rendue par renderView : rien à faire — le framework signe chaque <script> et <style> écrit dans le gabarit (viewNonce.ts#signTemplateTags), jamais ce que les données y insèrent ; le nonce reste lisible ailleurs par <%= it.cspNonce %>. Un <script> dont un attribut vient des données (src="<%= it.url %>") n’est pas signé : le signer exécuterait l’URL qu’elles apportent. Si tu la tiens pour sûre, signe-le toi-même (<script nonce="<%= it.cspNonce %>" src="<%= it.url %>">).

Une bibliothèque CSS-in-JS, dans cet ordre de préférence :

  1. « zéro runtime » (vanilla-extract, Linaria, Panda CSS…) : elle écrit des feuilles au build, servies en <link> par 'self' — rien à signer, rien à régler ;
  2. qui accepte un nonce : donne-lui readCspNonce() (nodefony/client) — Emotion : createCache({ key: "css", nonce: readCspNonce() }), Mantine : getStyleNonce, goober : window.__nonce__ = readCspNonce() avant son premier appel. Cherche ce crochet avant de conclure qu’il n’existe pas : il porte souvent un nom de variable globale ;
  3. sans option de nonce : elle crée un <style> conteneur que la politique refuse, et ses styles ne s’appliquent pas, même si elle y écrit ensuite par le CSSOM. Signe toi-même les <style> que les scripts créent, AVANT d’importer la bibliothèque (premier import de ton entrée) :
import { readCspNonce } from "nodefony/client";

const nonce = readCspNonce();
if (nonce) {
  // Une bibliothèque qui réécrit `nonce` avec une valeur vide (`undefined`) écraserait la
  // signature sans un mot : l'affectation d'une valeur vide rend le nonce de la page.
  const base = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "nonce");
  if (base?.get && base.set) {
    const { get, set } = base;
    Object.defineProperty(HTMLStyleElement.prototype, "nonce", {
      configurable: true,
      get() {
        return get.call(this);
      },
      set(value: unknown) {
        const usable =
          typeof value === "string" && value !== "" && value !== "undefined";
        set.call(this, usable ? value : nonce);
      },
    });
  }
  Document.prototype.createElement = new Proxy(
    Document.prototype.createElement,
    {
      apply(target, thisArg, args) {
        const element: unknown = Reflect.apply(target, thisArg, args);
        if (element instanceof HTMLStyleElement) element.nonce = nonce;
        return element;
      },
    },
  );
}

Le geste ne signe que ce qu’un script CRÉE : un <style> glissé dans la page par une injection HTML (innerHTML, gabarit) reste refusé. Il modifie deux méthodes natives pour toute la page — c’est ce qui le réserve à ce cas. Éprouvé sous Chrome, Firefox et WebKit, écriture par nœud texte comme par insertRule.

Dernier recours, et global : retirer le nonce des seuls styles en réécrivant la politique — la défense des scripts reste entière, seule celle contre l’exfiltration par CSS injecté tombe.

use("@nodefony/security", {
  headers: {
    // Le défaut, sauf `style-src` : plus de nonce pour les styles, scripts inchangés.
    csp:
      "default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
      "style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; " +
      "connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; " +
      "form-action 'self'; upgrade-insecure-requests",
  },
});

Un @Csp de route ne peut pas le faire à sa place : il AJOUTE des sources, et un navigateur ignore 'unsafe-inline' tant qu’un nonce figure dans la directive (CSP3 § « Does a source list allow all inline behavior »).

Là où le nonce est retiré (page servie telle quelle, cspNonces: false), 'unsafe-inline' reprend la main : la page garde ses styles.

Derrière un proxy inverse (nginx, haproxy, CDN) : la politique à nonce vient de l’application, jamais du proxy. Le nonce change à chaque réponse et n’existe que dans l’application, qui l’écrit à la fois dans l’en-tête et dans la page. Un proxy relaie l’en-tête tel quel — éprouvé derrière de vrais nginx et haproxy (reverse-proxy.test.ts) : une seule politique, le même nonce que la page, upgrade-insecure-requests posé seulement quand le client arrive en https. Trois configurations de proxy cassent ce contrat :

  • le proxy AJOUTE sa propre CSP (add_header Content-Security-Policy …, http-response add-header …) : le navigateur applique alors les DEUX politiques, et une ressource doit passer chacune (CSP3 § « The effect of multiple policies »). Celle du proxy n’a pas le nonce : tes scripts et tes styles signés sont bloqués. N’y mets rien qui touche aux scripts ou aux styles — ou rien du tout ;
  • le proxy REMPLACE la CSP (proxy_hide_header puis add_header) : le nonce disparaît de la politique, il ne protège plus rien. Si l’exploitation tient à écrire la politique au proxy, coupe celle de l’application (csp: "") : c’est alors une politique sans nonce, en connaissance de cause ;
  • le proxy ou le CDN MET EN CACHE une page signée : il sert le même nonce à tous les visiteurs, et un nonce connu d’avance n’autorise plus rien de façon sûre. Nodefony envoie Cache-Control: private, no-cache sur toute page à nonce (sauf si ton contrôleur en écrit un autre) : un cache qui respecte la norme ne la garde pas. Une règle qui l’ignore (proxy_ignore_headers Cache-Control, « tout mettre en cache » d’un CDN) la remet en cache : n’en pose jamais sur une page à nonce. Les fichiers JS, CSS et images gardent leur cache long.

Poser la CSP au proxy (nginx, ingress)#

Tu préfères écrire la politique dans nginx ou l’ingress ? C’est permis, en un réglage : l’application se retire au lieu de doubler la politique.

// nodefony/config/security.ts
export default (ctx) =>
  ({
    headers: { csp: "" }, // l'application n'envoie plus de CSP : le proxy décide seul
  }) satisfies ISecurityConfigInput;
  • Au proxy, une politique sans nonce : il ne connaît pas celui que l’application écrit dans la page. Point de départ sûr : la politique par défaut (npx nodefony inspect config, clé headers.csp de @nodefony/security) privée de ses 'nonce-{{nonce}}' et de 'strict-dynamic' — celle que Nodefony pose déjà sur une page servie telle quelle.
  • Ce que tu perds : la politique ne juge plus que l’origine. Un script injecté qui pointe vers un fichier de ton propre domaine (fichier téléversé, JSONP) passe, là où le nonce le bloquait.
  • Ce qui ne change pas : Referrer-Policy, COOP et les autres en-têtes restent posés par l’application. Aucun Cache-Control n’est imposé : sans nonce, la page redevient cachable.
  • Rejoue tes pages derrière le proxy avant de conclure : un script écrit en ligne dans la page, sans nonce, y est bloqué.
  • Jamais les deux à la fois : CSP de l’application ET CSP du proxy, le navigateur applique les deux (première puce ci-dessus).

upgrade-insecure-requests ne part que sur une page servie en https. Le navigateur réécrit alors en https chaque sous-ressource écrite en http:// (image, script, socket) au lieu de la charger en clair. Sur une page servie en http — le développement sur http://localhost — la même réécriture viserait un https que le serveur ne sert pas, et la page perdrait ses ressources : la directive y est donc retirée. Les deux variantes de la politique sont composées au démarrage (securityHeaders.ts#SecurityHeaders) ; par réponse, Nodefony lit seulement le schéma de la requête, qui tient compte du proxy de confiance. Derrière un proxy qui termine le TLS, régler trustProxy du module http, sinon la page est vue en clair et la directive ne part pas. Pour la couper partout, la retirer de csp ; pour l’avoir dans une politique écrite à la main, l’y ajouter.

base-uri reste à 'self' plutôt qu’à 'none' : 'self' empêche déjà une balise <base> injectée de détourner les scripts vers un hôte tiers, et 'none' casserait les applications qui posent <base href="/"> ou vivent sous un sous-chemin.

Pourquoi ce compromis : un CSP qui casse l’application est désactivé par le premier développeur pressé. Un CSP strict là où ça compte (script-src) et permissif là où ça ne coûte rien (styles, images) survit en production — c’est celui-là qui protège vraiment.

Le CSP couvre aussi le clickjacking, via frame-ancestors, plus finement que X-Frame-Options (liste d’origines plutôt que tout-ou-rien). Les deux cohabitent : un navigateur CSP ignore X-Frame-Options dès que frame-ancestors est présent, l’ancien en-tête reste le filet des autres. Un seul réglage pilote les deux — frameOptions du module http (section suivante) : security en dérive frame-ancestors (csp.ts#withFrameAncestors), sauf si ta politique déclare déjà la directive. C’est ainsi qu’on autorise un portail tiers à t’encadrer :

use("@nodefony/security", {
  headers: {
    csp: "default-src 'self'; script-src 'nonce-{{nonce}}' 'self'; frame-ancestors https://portail.example",
  },
});
X-Frame-Optionsnon, tu ne m’encadres pas#

La menace : le clickjacking. Ta page est superposée, invisible, à une page appât ; le clic de la victime est capté par ton interface.

Posé par le transport depuis un cache calculé au boot — secFrameOptions (http-kernel.ts#HttpKernel.secFrameOptions) — et configuré côté @nodefony/http avec frameOptions (http/nodefony/config/config.ts#securityHeadersSchema), qui vaut DENY par défaut. SAMEORIGIN si ton propre site s’auto-encadre. C’est un des trois en-têtes que security ne ré-émet pas : il doit valoir aussi pour un HTML statique servi directement depuis public/.

Le même réglage fixe frame-ancestors dans la politique de contenu :

frameOptions X-Frame-Options frame-ancestors
DENY (défaut) DENY 'none'
SAMEORIGIN SAMEORIGIN 'self'
null absent absent

Une édition à chaud de frameOptions prévient le pare-feu (firewall.ts#Firewall.onTransportHeadersChanged) : les deux en-têtes ne divergent jamais.

X-Content-Type-Optionsarrête de deviner#

La menace : le MIME sniffing. Un fichier téléversé, servi avec un Content-Type imprécis, est « deviné » par le navigateur — et un fichier deviné exécutable s’exécute sur ton origine, donc avec tes cookies.

Valeur unique reconnue : nosniff, posée depuis le cache secContentTypeOptions (http-kernel.ts#HttpKernel.secContentTypeOptions). C’est l’en-tête qui justifie le mieux la couche transport : le danger vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404 (security-headers.test.ts).

Strict-Transport-SecurityHTTPS, et rien d’autre#

La menace : le downgrade. Sur un réseau hostile, la toute première requête en clair suffit à installer un intercepteur.

La chaîne est assemblée au boot par HttpKernel.computeSecurityHeaderCaches() (http-kernel.ts#HttpKernel.computeSecurityHeaderCaches) : max-age, puis includeSubDomains et preload selon la config.

Elle n’est posée que sur une réponse HTTPS ou HTTP/2 — le cache secHsts est conditionné au type de serveur (http-kernel.ts#HttpKernel.secHsts). C’est conforme à la RFC 6797, qui veut qu’un HSTS reçu en clair soit ignoré : l’émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines inclus.

🛑 Prudence

preload: true (http/nodefony/config/config.ts#strictTransportSecuritySchema) inscrit ton domaine dans la liste pré-chargée des navigateurs. C’est un engagement quasi irréversible : tout sous-domaine incapable de servir en HTTPS devient inaccessible, et la sortie de liste prend des mois. À ne jamais activer « pour voir ».

Referrer-Policyne raconte pas d’où tu viens#

La menace : la fuite d’URL. Chemins parlants, identifiants de session dans une query, jetons de réinitialisation — tout part chez le site suivant via le Referer.

Défaut Nodefony : no-referrer (security/nodefony/config/config.ts#headersSchema), la valeur la plus stricte. La valeur est un enum W3C fermé — huit valeurs validées au boot, donc pas de faute de frappe qui passerait en silence (l’écriture libre no-refferer casserait la protection sans prévenir).

Le choix usuel pour un site public reste strict-origin-when-cross-origin : URL complète en interne, origine seule vers l’extérieur, rien du tout vers du HTTP en clair.

Cross-Origin-Opener-Policycoupe le lien avec la fenêtre ouvrante#

La menace : une page ouverte par la tienne (ou qui t’a ouverte) garde une référence window.opener et partage un groupe de contexte de navigation — surface d’attaque pour du tabnabbing et pour les canaux auxiliaires type Spectre.

same-origin (securityHeaders.ts#SecurityHeaders.constructor) rompt ce lien. C’est aussi, avec COEP, l’une des deux conditions de l’isolation cross-origin, indispensable si tu veux SharedArrayBuffer ou des timers haute résolution.

Lu sur : un document (page HTML) ouvert dans une fenêtre. Sur un JSON, une image, un script, le navigateur l’ignore — aucune fenêtre n’y est ouverte.

Exemple : ta page de connexion ouvre la fenêtre d’un fournisseur OAuth. Sans COOP, ce site garde window.opener et peut, s’il est compromis, rediriger ton onglet vers une copie de ta page (tabnabbing). Avec same-origin, la référence vaut null. ⚠️ Un flux de connexion par fenêtre surgissante qui renvoie son résultat par window.opener.postMessage cesse alors de fonctionner : same-origin-allow-popups garde le lien vers les fenêtres que TA page ouvre.

Cross-Origin-Embedder-Policyje n’embarque que du consenti#

La menace : ta page embarque des ressources tierces qui n’ont jamais donné leur accord, et les place dans ton processus.

require-corp (securityHeaders.ts#SecurityHeaders.constructor) exige que chaque ressource tierce s’annonce comme partageable (CORP ou CORS). C’est le complément de COOP pour l’isolation complète.

Lu sur : un document, et le script d’un worker (un worker charge lui aussi des ressources). Sur une image ou un JSON, il ne contrôle rien.

Exemple : ta page affiche <img src="https://cdn.tiers.example/logo.png">. Avec require-corp, l’image ne s’affiche que si le CDN répond Cross-Origin-Resource-Policy: cross-origin (ou sert l’image en CORS). credentialless est le compromis : la ressource tierce est chargée SANS cookies, et n’a plus besoin de consentir.

⚠️ Attention

coep: "require-corp" casse toutes les ressources tierces non conformes — polices Google, images de CDN, iframes de paiement. C’est pour cette raison qu’il est absent des défauts, et volontairement exclu du banc de test (security-headers.test.ts). À activer seulement si tu as besoin de l’isolation cross-origin, et après audit de tes assets.

Cross-Origin-Resource-Policymes assets ne s’incluent pas ailleurs#

La menace : symétrique du précédent. Un site tiers inclut tes images ou tes scripts pour les mesurer, les mettre en cache, ou monter une attaque par inclusion.

same-origin (securityHeaders.ts#SecurityHeaders.constructor) interdit toute inclusion externe ; same-site autorise tes propres sous-domaines ; cross-origin ouvre — c’est ce qu’il faut sur une CDN publique assumée.

Lu sur : toute ressource incluse sans CORS — image, script, média, JSON appelé par une balise. C’est l’en-tête des ressources, là où COOP et COEP sont ceux des pages.

Exemple : https://evil.example écrit <img src="https://ton-app/api/me">. Le navigateur y joint les cookies de sa victime, et la réponse — son profil — atterrit dans le processus de la page hostile, où une attaque de type Spectre peut la lire. Avec same-origin, le navigateur bloque la réponse AVANT qu’elle n’entre dans ce processus.

Origin-Agent-Clusterun bac à sable par origine#

La menace : plusieurs origines partageant heap et processus, donc des canaux auxiliaires mesurables.

Nodefony l’émet comme un booléen de champ structuré RFC 9651 : la valeur est littéralement ?1 (securityHeaders.ts#SecurityHeaders.constructor). C’est une demande, pas une garantie — le navigateur décide.

Permissions-Policycoupe le micro par défaut#

La menace : une iframe tierce (widget, publicité) demande la caméra, le micro ou la position, et la boîte de dialogue s’affiche sous ton nom de domaine.

Valeur libre — le champ permissionsPolicy est recopié tel quel (securityHeaders.ts#ISecurityHeadersOptions.permissionsPolicy), typiquement camera=(), microphone=(), geolocation=() : la liste vide signifie « personne, pas même moi ». Absent par défaut car la liste des fonctionnalités dépend entièrement de l’application.

⚙️ Configuration — deux sections, deux modules#

Réflexe à acquérir : le nom du module dit qui pose l’en-tête. Chercher frameOptions dans la config security est la première source de confusion sur ce sujet.

Couche applicative — use("@nodefony/security", { headers })#

Dérivé du schéma Zod headersSchema (config.ts#headersSchema).

Option Type Défaut Effet
enabled booléen true Coupe toute la couche applicative.
csp chaîne politique « secure-but-usable » Valeur de Content-Security-Policy.
cspNonces booléen true Active la substitution de {{nonce}} par requête.
referrerPolicy enum W3C (8 valeurs) no-referrer Valeur de Referrer-Policy.
coop enum, optionnel absent Cross-Origin-Opener-Policy.
coep enum, optionnel absent Cross-Origin-Embedder-Policy.
corp enum, optionnel absent Cross-Origin-Resource-Policy.
originAgentCluster booléen, optionnel absent Émet Origin-Agent-Cluster: ?1.
permissionsPolicy chaîne, optionnelle absent Valeur de Permissions-Policy.

Socle transport — use("@nodefony/http", { securityHeaders })#

Dérivé de securityHeadersSchema (http/nodefony/config/config.ts#securityHeadersSchema). Ces trois réglages sont éditables à chaud (runtimeMutable) : HttpKernel.onConfigChanged() (http-kernel.ts#HttpKernel.onConfigChanged) recalcule les caches, donc la valeur suivante s’applique sans redémarrage.

Option Type Défaut Effet
contentTypeOptions chaîne ou null nosniff X-Content-Type-Options ; null = ne pas émettre.
frameOptions chaîne ou null DENY X-Frame-Options ET frame-ancestors ; SAMEORIGIN si auto-encadrement.
strictTransportSecurity objet ou null activé null = pas de HSTS du tout.
strictTransportSecurity.maxAge entier (secondes) 31536000 Durée mémorisée par le navigateur (un an, recommandé OWASP).
strictTransportSecurity.includeSubDomains booléen true Étend la contrainte à tous les sous-domaines.
strictTransportSecurity.preload booléen false Inscription à la liste pré-chargée — irréversible en pratique.

⚠️ Attention

Les clés hsts, hstsMaxAgeS, frameguard et noSniff existent dans la config security (config.ts#headersSchema) mais ne pilotent rien : la couche applicative ne les lit pas (securityHeaders.ts#ISecurityHeadersOptions). frameguard est dépréciée (retrait à la prochaine version majeure). Pour changer réellement X-Frame-Options et frame-ancestors, c’est securityHeaders.frameOptions du module http. Même remarque pour hidePoweredBy (config.ts#headersSchema) : Nodefony n’émet aucun X-Powered-By, l’option est un no-op documenté.

🏗️ Le CSP en détail — deux régimes, trois façons de l’étendre#

Régime 1 — CSP statique (0 allocation par requête)#

Si le CSP ne contient pas {{nonce}}, ou si cspNonces est à false, la chaîne est rangée telle quelle dans la table gelée du boot (securityHeaders.ts#SecurityHeaders.constructor). Par requête : une lecture, un setHeader. Rien d’autre.

Détail de robustesse : si tu désactives cspNonces en laissant le placeholder dans la chaîne, le token résiduel 'nonce-{{nonce}}' est purgé (securityHeaders.ts#SecurityHeaders.constructor). Sans ça, tu servirais un CSP contenant un nonce littéral jamais émis — donc un script-src qui bloque tout, y compris tes propres scripts. 'strict-dynamic' part avec lui quand la directive n’a plus ni nonce ni hash : seul, il ferait ignorer 'self' et bloquerait aussi tout. Le code refuse de produire un CSP cassé.

Régime 2 — nonce par requête (la vraie défense anti-XSS)#

Un nonce autorise l’inline que tu as toi-même rendu, et lui seul. Un script injecté par un attaquant ne peut pas deviner la valeur : il est refusé même s’il est syntaxiquement identique.

Le chemin complet, sans surprise :

  1. Au boot, la chaîne CSP est pré-découpée autour de {{nonce}} (securityHeaders.ts#SecurityHeaders.constructor). Aucun parsing ni regex n’aura lieu pendant une requête.
  2. Par requête, Firewall.applySecurityHeaders() (firewall.ts#Firewall.applySecurityHeaders) lit context.cspNonce — ce qui génère le jeton à cet instant (Context.ts#Context.#cspNonce) — puis appelle SecurityHeaders.cspFor() (securityHeaders.ts#SecurityHeaders.cspFor) : un seul join.
  3. Dans la vue, le contrôleur relit context.cspNonce, qui est mémoïsé : l’en-tête et le <script nonce="…"> portent forcément la même valeur. C’est le motif employé par le contrôleur de Studio (StudioController.ts#StudioController.renderStudio).

SecurityHeaders.hasNonce (securityHeaders.ts#SecurityHeaders.hasNonce) est le drapeau qui décide du régime : à false, pas une seule opération crypto. Il n’y a aucun setter pour cspNonce : un jeton serveur doit rester imprévisible, jamais pilotable par le client — contrairement au requestId, qui, lui, accepte une corrélation entrante.

Placement dans le pipeline : applySecurityHeaders est appelé après le resolve et avant le repli statique et le writeHead (http-kernel.ts#HttpKernel.runHttpPipeline). Cet ordre n’est pas cosmétique : il faut que le routeur ait posé les directives @Csp de la route pour pouvoir les fusionner, et il faut être avant l’écriture des en-têtes pour pouvoir en poser.

Quelle politique sur quelle réponse — le type décide#

Une politique de contenu ne protège que ce que le navigateur rend : une page, un SVG, le script d’un worker. Sur un JSON ou une image, elle ne gouverne rien — sauf l’encadrement. Le pare-feu choisit donc la politique au moment où la réponse écrit ses en-têtes, d’après son Content-Type (securityHeaders.ts#responseKindOf, ADR-0024 §C11) :

La réponse est… Content-Security-Policy Autres en-têtes applicatifs
un document : HTML, ou tout type XML (text/xml, *+xml, dont SVG) complète, avec nonce tous
un document servi tel quel (fichier de public/, relais) complète, sans nonce ni 'strict-dynamic' (styles : 'unsafe-inline') tous
du JavaScript (text/javascript…) complète, sans nonce (celle qu’un worker prend) Referrer-Policy, COEP, CORP
autre chose : JSON, CSS, image, police, média, 204 default-src 'none', plus le frame-ancestors de la complète Referrer-Policy, CORP
un 304 Not Modified du pipeline (contrôleur) aucune aucun

Pourquoi chaque ligne :

Ce que l’application écrit n’est jamais réduit :

Étendre le CSP — quatre portées, une seule mécanique#

Portée Outil Pour quoi Recalculé
Application headers.csp en config ta politique de base au boot
Application headers.cspSources en config ajouter des sources sans réécrire la base au boot
Module Firewall.registerCspOrigins() besoin permanent d’un module (ex. Vite) à l’enregistrement
Route / contrôleur @Csp({ … }) besoin ponctuel d’une réponse (iframe, CDN) par requête décorée

headers.cspSources — ajouter sans réécrire. Recopier toute la politique pour y ajouter un hôte la figerait : elle ne suivrait plus le défaut. On déclare plutôt des sources par contribution nommée :

use("@nodefony/security", {
  headers: { cspSources: { cdn: { "img-src": ["https://cdn.example.com"] } } },
});

Indexée par NOM, et non par directive, parce que la fusion de configuration remplace un tableau case par case : un module qui complète la section par module-security sous un autre nom s’ajoute, il n’écrase pas. Une contribution malformée est ignorée et journalisée, l’application démarre ; une source ouverte à tout hôte est gardée (c’est ton choix) et signalée. Sans effet quand csp vaut "".

Les quatre convergent vers mergeCspFragments() (csp.ts#mergeCspFragments), et c’est un merge structuré, pas une concaténation. Pourquoi c’est vital : en CSP, une directive répétée est ignorée après sa première occurrence (W3C CSP Level 3 §3, rationnel documenté csp.ts#CspFragment). Concaténer "script-src 'self'" et "script-src https://cdn" produirait un en-tête où la seconde est purement et simplement jetée — une extension silencieusement sans effet, le pire des deux mondes.

Le merge fusionne donc les sources dans une seule directive, dédoublonnées, base d’abord ; une directive absente est ajoutée en fin. La fonction est pure et déterministe (parseCsp() csp.ts#parseCsp → serializeCsp() csp.ts#serializeCsp), ce qui rend l’en-tête stable d’une requête à l’autre et les tests fiables.

Coût : le merge d’un module est payé une fois, au (dés)enregistrement (Firewall.#rebuildSecurityHeaders(), firewall.ts#Firewall.#rebuildSecurityHeaders), jamais par requête. Le merge d’une route @Csp est payé uniquement sur les routes décorées (SecurityHeaders.cspForExtra(), securityHeaders.ts#SecurityHeaders.cspForExtra) ; le cas courant reste le simple join.

Rapports de violation — voir ce que la politique bloque#

Une politique stricte casse sans bruit : le navigateur bloque une image ou un script, et le serveur n’en sait rien. Par défaut, la politique demande donc ses rapports — report-uri (lu par tous les navigateurs) et report-to nodefony-csp (Reporting API, honorée seulement vers une origine sûre : https ou localhost), avec l’en-tête Reporting-Endpoints qui nomme le groupe :

Content-Security-Policy: …; report-uri /nodefony/security/api/csp-report; report-to nodefony-csp
Reporting-Endpoints: nodefony-csp="/nodefony/security/api/csp-report"

Le navigateur poste ses rapports sur POST /nodefony/security/api/csp-report (CspReportController.ts#CspReportController). Cette porte est publique — le navigateur n’envoie aucun identifiant — donc tout ce qu’elle reçoit est traité comme écrit par un attaquant (cspReports.ts#CspReportCollector) :

La réponse est toujours 204 : l’émetteur n’apprend rien de ce qui a été gardé. L’administrateur lit les violations sur GET /nodefony/security/api/csp-reports (rôle ROLE_NODEFONY_ADMIN), la plus récente d’abord — champs à afficher comme du texte.

headers.cspReport Effet
true (défaut) rapports vers la route du framework, collectés et lisibles par l’administrateur
"https://…", "/…" rapports vers ton collecteur ; aucune route de réception côté application
false aucun rapport demandé

Une politique que tu écris avec son propre report-uri ou report-to est servie telle quelle. Une URL qu’un en-tête ne peut pas porter est retirée et journalisée en ERROR — l’application démarre sans rapports plutôt que de ne pas démarrer.

🧩 Extension — déclarer un fragment CSP depuis son module#

Un module qui a besoin d’origines supplémentaires ne doit jamais poser l’en-tête lui-même : il en émettrait un second, et le navigateur applique alors l’intersection la plus stricte — au mieux inefficace, au pire il casse la page. Il déclare son besoin, security fusionne.

// Dans le service d'un module — le firewall est résolu PAR NOM et typé par le
// contrat de @nodefony/http (aucun import de security).
import type { IFirewallGate } from "@nodefony/http";

this.get<IFirewallGate>("firewall")?.registerCspOrigins?.("mon-module", {
  "connect-src": ["https://api.partenaire.tld"],
  "img-src": ["https://cdn.partenaire.tld"],
});

Quatre propriétés à retenir :

L’exemple de référence vit dans le framework : en développement, @nodefony/frontend déclare les origines du serveur Vite et 'unsafe-eval' (exigé par le Fast Refresh de React) via FrontendService.#viteCspFragment() (FrontendService.ts#FrontendService.#viteCspFragment) — ce qui explique qu’un CSP observé en dev soit plus large qu’en production, où ce fragment n’existe pas.

📜 Normes appliquées#

Domaine Norme Ancrage dans le code
Politique de sécurité du contenu W3C CSP Level 3 mergeCspFragments() (csp.ts#mergeCspFragments), directive non dupliquée
Nonce CSP (unicité, imprévisibilité) W3C CSP Level 3 §6.7.4 Context.cspNonce — 128 bits CSPRNG (Context.ts#Context.#cspNonce)
HSTS RFC 6797 posé sur TLS uniquement (http-kernel.ts#HttpKernel.getErrorRenderer)
Champ structuré booléen RFC 9651 Origin-Agent-Cluster: ?1 (securityHeaders.ts#SecurityHeaders.constructor)
Referrer-Policy W3C Referrer Policy (enum fermé) 8 valeurs validées au boot (config.ts#headersSchema)
Isolation cross-origin WHATWG HTML (COOP/COEP/CORP) securityHeaders.ts#SecurityHeaders.constructor
Anti-MIME-sniffing WHATWG Fetch (nosniff) secContentTypeOptions (http-kernel.ts#HttpKernel.secContentTypeOptions)
Durcissement en-têtes OWASP Secure Headers computeSecurityHeaderCaches() (http-kernel.ts#HttpKernel.computeSecurityHeaderCaches)

⚡ Performance & mémoire#

Le coût est concentré au boot, par construction :

Le module n’attache aucun écouteur d’événement et ne conserve aucun état par requête : il n’entre pas dans le périmètre du gate mémoire, qu’il ne peut structurellement pas dégrader.

📡 Observabilité — Studio#

L’écran Firewall de Studio affiche la section « En-têtes de sécurité » — pilotée par headers.enabled (FirewallDefenses.tsx#FirewallDefenses) — avec le CSP effectif, l’état du nonce par requête, la Referrer-Policy et les valeurs d’isolation. Les données viennent de Firewall.describe() (firewall.ts#Firewall.describe), qui projette la config sans aucun secret, exposée par GET /nodefony/security/api/firewall.

L’onglet Configuration de Studio rend les mêmes options depuis le schéma Zod — chaque champ y porte sa description, ce qui en fait la référence toujours à jour des défauts.

ℹ️ Note

La ligne « frameguard » de cet écran reflète la valeur réellement ÉMISE par le transport, lue chez le noyau HTTP (firewall.ts#Firewall.describe), pas la clé inerte de la config security. La réponse HTTP elle-même reste la preuve : curl -I tranche en une seconde.

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause dans le code Correction
frameguard: "sameorigin" en config security sans effet Clé inerte — non lue par la couche applicative (securityHeaders.ts#ISecurityHeadersOptions) Régler securityHeaders.frameOptions du module http
<script> inline bloqué Le template ne reprend pas le nonce Rendre <script nonce="…"> depuis context.cspNonce
CSP contenant 'nonce-{{nonce}}' littéral cspNonces désactivé, placeholder laissé Nodefony purge le résiduel (securityHeaders.ts#ISecurityHeadersOptions.cspNonces) ; retirer le placeholder
Une extension script-src de module sans effet Second en-tête / directive dupliquée (ignorée, W3C CSP3 §3) Déclarer un fragment via registerCspOrigins(), jamais un setHeader
Assets tiers cassés après activation de l’isolation coep: "require-corp" exige CORP/CORS sur chaque ressource Retirer coep ou faire annoncer les ressources
HSTS absent en développement Posé sur TLS uniquement (http-kernel.ts#HttpKernel.getErrorRenderer) Comportement conforme RFC 6797 — vérifier sur le port HTTPS
Le CSP est plus large en dev qu’en prod @nodefony/frontend déclare les origines Vite (FrontendService.ts#FrontendService.#buildEntries) Attendu : le fragment n’existe pas en production
Violation en console, mais aucun rapport reçu Le navigateur n’envoie pas de rapports — Brave les bloque (vie privée), en report-uri comme en report-to Éprouver sous Chrome, Edge ou Firefox ; l’onglet Réseau doit montrer POST csp-report → 204
Aucun rapport reçu en report-to en développement La Reporting API n’envoie que vers une origine sûre (https, localhost) Attendu : report-uri porte les rapports en http ; tester sur le port HTTPS
Aucun en-tête applicatif Module security absent ou headers.enabled: false Le socle transport reste actif ; réactiver security pour CSP/Referrer/isolation
Domaine injoignable après activation de preload Inscription à la liste pré-chargée, sortie très lente Ne jamais activer sans plan HTTPS sur tous les sous-domaines

🧪 Tests & couverture#

Trois familles couvrent la brique — les compteurs exacts vivent dans la carte de l’aperçu, régénérée depuis vitest :

Couverture : npm run coverage dans @nodefony/security. Revue de sécurité d’un diff touchant cette brique : skill nodefony-security-review.

🔗 Pour aller plus loin#