Cours JavaScript et React · Module 10: Écosystème et avenir de React
Pratiques de sécurité complètes dans React
Dans cette leçon6
Il suffit d'un seul champ de commentaire non contrôlé pour qu'un signal étranger prenne les commandes : l'attaquant colle du HTML contenant un script, et le navigateur de chaque passager exécute ce script. La sécurité d'une application React, ce sont plusieurs couches de boucliers, de l'échappement dans le JSX aux tokens et aux dépendances, jusqu'aux règles de l'OWASP Top 10.
1. Cross-Site Scripting (XSS)
Le XSS consiste à injecter un script qui s'exécutera dans le navigateur de la victime. Par défaut, React échappe chaque valeur placée entre accolades dans le JSX, en transformant les caractères spéciaux HTML en entités sûres :
1// React échappe automatiquement ces valeurs
2function UserProfile({ userData }) {
3 return (
4 <div>
5 {/* Sûr - React échappe le contenu */}
6 <h1>{userData.name}</h1>
7 <p>{userData.description}</p>
8
9 {/* DANGEREUX - contourne l'échappement */}
10 <div dangerouslySetInnerHTML={{ __html: userData.bio }} />
11 </div>
12 );
13}{userData.name} s'affichera comme du texte, même si quelqu'un y saisit une balise <script>. L'exception est dangerouslySetInnerHTML, qui insère du HTML brut et contourne cette protection.
Assainissement avec DOMPurify
Quand tu dois afficher du HTML provenant d'une source non fiable, par exemple le contenu d'un éditeur de texte, fais-le d'abord passer par DOMPurify :
1// Composants sûrs avec validation
2function SafeHTMLRenderer({ content, allowedTags = [] }) {
3 // Bibliothèque DOMPurify pour assainir le HTML
4 const cleanHTML = DOMPurify.sanitize(content, {
5 ALLOWED_TAGS: allowedTags,
6 ALLOWED_ATTR: ['href', 'title', 'alt', 'src'],
7 ALLOW_DATA_ATTR: false
8 });
9
10 return (
11 <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
12 );
13}
14
15// Validation des URL
16function SafeLink({ href, children, ...props }) {
17 const isValidURL = (url) => {
18 try {
19 const urlObj = new URL(url);
20 // N'autoriser que les protocoles sûrs
21 return ['http:', 'https:', 'mailto:'].includes(urlObj.protocol);
22 } catch {
23 return false;
24 }
25 };
26
27 if (!isValidURL(href)) {
28 console.warn('URL dangereuse bloquée :', href);
29 return <span>{children}</span>;
30 }
31
32 return (
33 <a
34 href={href}
35 {...props}
36 rel="noopener noreferrer" // Protection contre le tabnabbing
37 >
38 {children}
39 </a>
40 );
41}
42
43// Rendu sûr des images
44function SafeImage({ src, alt, ...props }) {
45 const [imageSrc, setImageSrc] = useState(null);
46 const [error, setError] = useState(false);
47
48 useEffect(() => {
49 // Validation de la source de l'image
50 if (src && typeof src === 'string') {
51 try {
52 const url = new URL(src);
53 if (['http:', 'https:', 'data:'].includes(url.protocol)) {
54 setImageSrc(src);
55 } else {
56 setError(true);
57 }
58 } catch {
59 setError(true);
60 }
61 }
62 }, [src]);
63
64 if (error) {
65 return <div className="image-error">Source d'image invalide</div>;
66 }
67
68 return imageSrc ? (
69 <img
70 src={imageSrc}
71 alt={alt}
72 {...props}
73 onError={() => setError(true)}
74 />
75 ) : null;
76}La règle est la suivante : dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }}, jamais de HTML brut. Tu n'as pas besoin d'assainir le texte simple des champs de formulaire, car React l'échappe de toute façon. SafeLink ne laisse passer que les protocoles http:, https: et mailto:, et SafeImage vérifie la source de l'image.
Content Security Policy
Le deuxième bouclier agit dans le navigateur : l'en-tête Content Security Policy indique d'où l'on peut charger les scripts, les styles et les images :
1// Configuration de l'en-tête CSP
2const cspConfig = {
3 'default-src': ["'self'"],
4 'script-src': [
5 "'self'",
6 "'unsafe-inline'", // Uniquement en développement
7 'https://apis.google.com',
8 'https://cdn.jsdelivr.net'
9 ],
10 'style-src': [
11 "'self'",
12 "'unsafe-inline'", // Pour le CSS-in-JS
13 'https://fonts.googleapis.com'
14 ],
15 'img-src': [
16 "'self'",
17 'data:',
18 'https:',
19 'blob:'
20 ],
21 'font-src': [
22 "'self'",
23 'https://fonts.gstatic.com'
24 ],
25 'connect-src': [
26 "'self'",
27 'https://api.example.com',
28 'wss://socket.example.com'
29 ],
30 'frame-ancestors': ["'none'"],
31 'form-action': ["'self'"],
32 'upgrade-insecure-requests': []
33};
34
35// Configuration de la CSP dans Next.js
36// next.config.js
37const securityHeaders = [
38 {
39 key: 'Content-Security-Policy',
40 value: Object.entries(cspConfig)
41 .map(([directive, sources]) =>
42 `${directive} ${sources.join(' ')}`
43 )
44 .join('; ')
45 },
46 {
47 key: 'X-Frame-Options',
48 value: 'DENY'
49 },
50 {
51 key: 'X-Content-Type-Options',
52 value: 'nosniff'
53 },
54 {
55 key: 'Referrer-Policy',
56 value: 'strict-origin-when-cross-origin'
57 },
58 {
59 key: 'Permissions-Policy',
60 value: 'camera=(), microphone=(), geolocation=()'
61 }
62];
63
64module.exports = {
65 async headers() {
66 return [
67 {
68 source: '/(.*)',
69 headers: securityHeaders
70 }
71 ];
72 }
73};cspConfig devient un seul en-tête, et headers() dans next.config.js l'ajoute à chaque réponse. 'unsafe-inline' dans script-src désactive une grande partie de la protection, donc garde-le uniquement dans l'environnement de développement.
2. Sécurité des JWT
Un token JWT est un laissez-passer pour monter à bord, donc le voler revient à prendre le contrôle du compte. SecureTokenManager montre deux façons de le stocker, et useAuth vérifie la validité du token toutes les 5 minutes :
1// Stockage et gestion sécurisés des JWT
2class SecureTokenManager {
3 constructor() {
4 this.tokenKey = 'auth_token';
5 this.refreshTokenKey = 'refresh_token';
6 }
7
8 // Stockage dans des cookies httpOnly (le meilleur choix)
9 setTokens(accessToken, refreshToken) {
10 // Envoi au backend pour définir des cookies httpOnly
11 fetch('/api/auth/set-tokens', {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ accessToken, refreshToken })
15 });
16 }
17
18 // Alternative - sessionStorage (moins sûr)
19 setTokensInStorage(accessToken, refreshToken) {
20 // N'utilise JAMAIS localStorage pour les tokens !
21 sessionStorage.setItem(this.tokenKey, accessToken);
22
23 if (refreshToken) {
24 // Refresh token dans un endroit sûr
25 this.storeRefreshTokenSecurely(refreshToken);
26 }
27 }
28
29 getToken() {
30 return sessionStorage.getItem(this.tokenKey);
31 }
32
33 removeTokens() {
34 sessionStorage.removeItem(this.tokenKey);
35 sessionStorage.removeItem(this.refreshTokenKey);
36
37 // Déconnexion aussi des cookies
38 fetch('/api/auth/logout', { method: 'POST' });
39 }
40
41 // Validation du token
42 isTokenValid(token) {
43 if (!token) return false;
44
45 try {
46 // Le JWT encode le payload en base64url, alors que atob() attend du base64 classique
47 const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
48 const payload = JSON.parse(atob(base64));
49 const now = Date.now() / 1000;
50
51 // Vérifier si le token n'a pas expiré
52 return payload.exp > now;
53 } catch {
54 return false;
55 }
56 }
57
58 // Rafraîchissement automatique du token
59 async refreshTokenIfNeeded() {
60 const token = this.getToken();
61
62 if (!token || !this.isTokenValid(token)) {
63 return await this.refreshToken();
64 }
65
66 return token;
67 }
68
69 async refreshToken() {
70 try {
71 const response = await fetch('/api/auth/refresh', {
72 method: 'POST',
73 credentials: 'include' // Pour les cookies httpOnly
74 });
75
76 if (response.ok) {
77 const { accessToken } = await response.json();
78 sessionStorage.setItem(this.tokenKey, accessToken);
79 return accessToken;
80 }
81 } catch (error) {
82 console.error('Token refresh failed:', error);
83 this.removeTokens();
84 }
85
86 return null;
87 }
88}
89
90// Hook pour l'autorisation
91function useAuth() {
92 const [user, setUser] = useState(null);
93 const [loading, setLoading] = useState(true);
94 const tokenManager = useRef(new SecureTokenManager());
95
96 const login = async (credentials) => {
97 try {
98 const response = await fetch('/api/auth/login', {
99 method: 'POST',
100 headers: { 'Content-Type': 'application/json' },
101 body: JSON.stringify(credentials)
102 });
103
104 if (response.ok) {
105 const { user, accessToken, refreshToken } = await response.json();
106
107 // Stockage sûr des tokens
108 tokenManager.current.setTokens(accessToken, refreshToken);
109 setUser(user);
110
111 return { success: true };
112 }
113 } catch (error) {
114 return { success: false, error: error.message };
115 }
116 };
117
118 const logout = () => {
119 tokenManager.current.removeTokens();
120 setUser(null);
121 };
122
123 // Rafraîchissement automatique du token
124 useEffect(() => {
125 const interval = setInterval(async () => {
126 await tokenManager.current.refreshTokenIfNeeded();
127 }, 5 * 60 * 1000); // Toutes les 5 minutes
128
129 return () => clearInterval(interval);
130 }, []);
131
132 return { user, login, logout, loading };
133}L'option la plus sûre est un cookie httpOnly, que JavaScript ne peut pas lire, idéalement défini par le serveur dès la connexion. sessionStorage disparaît à la fermeture de l'onglet, mais protège aussi mal contre le XSS que localStorage, car n'importe quel script de ce domaine peut lire les deux. isTokenValid ne vérifie que la date d'expiration, et seul le serveur vérifie la signature.
Intercepteurs Axios
Les intercepteurs ajoutent des en-têtes à chaque requête et la relancent après le rafraîchissement du token :
1// Configuration sécurisée d'Axios
2const createSecureAxiosInstance = () => {
3 const tokenManager = new SecureTokenManager();
4
5 const axiosInstance = axios.create({
6 baseURL: process.env.REACT_APP_API_URL,
7 timeout: 10000,
8 withCredentials: true // Pour les cookies httpOnly
9 });
10
11 // Intercepteur des requêtes
12 axiosInstance.interceptors.request.use(
13 async (config) => {
14 const token = await tokenManager.refreshTokenIfNeeded();
15
16 if (token) {
17 config.headers.Authorization = `Bearer ${token}`;
18 }
19
20 // Ajouter le token CSRF s'il est disponible
21 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
22 if (csrfToken) {
23 config.headers['X-CSRF-Token'] = csrfToken;
24 }
25
26 return config;
27 },
28 (error) => Promise.reject(error)
29 );
30
31 // Intercepteur des réponses
32 axiosInstance.interceptors.response.use(
33 (response) => response,
34 async (error) => {
35 const originalRequest = error.config;
36
37 if (error.response?.status === 401 && !originalRequest._retry) {
38 originalRequest._retry = true;
39
40 const newToken = await tokenManager.refreshToken();
41
42 if (newToken) {
43 originalRequest.headers.Authorization = `Bearer ${newToken}`;
44 return axiosInstance(originalRequest);
45 } else {
46 // Rediriger vers la connexion
47 window.location.href = '/login';
48 }
49 }
50
51 return Promise.reject(error);
52 }
53 );
54
55 return axiosInstance;
56};Sur un statut 401, l'intercepteur rafraîchit le token et ne répète la requête qu'une seule fois, ce que surveille le drapeau _retry. L'en-tête X-CSRF-Token protège contre le CSRF, une attaque dans laquelle un site étranger envoie une requête avec les cookies de la victime. Ce token doit provenir d'un générateur cryptographiquement sûr, comme crypto.getRandomValues(), jamais de Math.random(), et le serveur le compare à la valeur stockée dans la session.
3. Variables d'environnement
Le fichier .env.example montre à l'équipe de quelles variables le projet a besoin, sans les vraies valeurs :
1# .env.example - modèle pour l'équipe, sans vraies valeurs
2# Les secrets comme API_SECRET_KEY ou DATABASE_URL ne vont jamais dans ce fichier
3# ni dans les variables REACT_APP_, car le navigateur les verra
4REACT_APP_API_URL=https://api.example.com
5REACT_APP_ENVIRONMENT=development
6REACT_APP_SENTRY_DSN=your_sentry_dsn_hereTout ce qui porte le préfixe REACT_APP_ se retrouve dans le bundle JavaScript, donc n'importe quel utilisateur peut le lire, et les secrets restent sur le serveur. Ce préfixe vient de Create React App, que l'équipe React a abandonné en 2025. Dans Vite l'équivalent est VITE_, dans Next.js NEXT_PUBLIC_, et la règle reste la même.
EnvironmentValidator vérifie au démarrage que les variables requises existent et interrompt le lancement s'il en manque une :
1// Validation des variables d'environnement
2class EnvironmentValidator {
3 static requiredVars = [
4 'REACT_APP_API_URL',
5 'REACT_APP_ENVIRONMENT'
6 ];
7
8 static validate() {
9 const missing = this.requiredVars.filter(
10 varName => !process.env[varName]
11 );
12
13 if (missing.length > 0) {
14 throw new Error(
15 `Missing required environment variables: ${missing.join(', ')}`
16 );
17 }
18 }
19
20 static get(key, defaultValue = null) {
21 const value = process.env[key];
22
23 if (!value && defaultValue === null) {
24 console.warn(`Environment variable ${key} is not set`);
25 }
26
27 return value || defaultValue;
28 }
29
30 static getSecure(key) {
31 const value = process.env[key];
32
33 if (!value) {
34 throw new Error(`Secure environment variable ${key} is required`);
35 }
36
37 return value;
38 }
39}
40
41// Configuration de l'application
42const appConfig = {
43 apiUrl: EnvironmentValidator.getSecure('REACT_APP_API_URL'),
44 environment: EnvironmentValidator.get('REACT_APP_ENVIRONMENT', 'development'),
45 isDevelopment: process.env.NODE_ENV === 'development',
46 isProduction: process.env.NODE_ENV === 'production',
47
48 // Fonctions utilitaires
49 get isSecure() {
50 return this.apiUrl.startsWith('https://');
51 },
52
53 get shouldEnableDevTools() {
54 return this.isDevelopment && !this.isProduction;
55 }
56};
57
58// Validation au démarrage de l'application
59EnvironmentValidator.validate();Une erreur au démarrage vaut mieux qu'une application qui envoie en silence des requêtes vers undefined. getSecure lève une exception, alors que get se contente d'avertir et renvoie une valeur par défaut.
ConfigProvider répartit les réglages selon l'environnement : débogage, niveau de logs et mocks :
1// ConfigProvider.jsx
2const ConfigContext = createContext();
3
4export function ConfigProvider({ children }) {
5 const config = useMemo(() => {
6 const baseConfig = {
7 apiUrl: process.env.REACT_APP_API_URL,
8 environment: process.env.REACT_APP_ENVIRONMENT,
9 version: process.env.REACT_APP_VERSION || '1.0.0'
10 };
11
12 // Configuration propre à chaque environnement
13 const environmentConfigs = {
14 development: {
15 debug: true,
16 logLevel: 'debug',
17 enableMocks: true
18 },
19 staging: {
20 debug: true,
21 logLevel: 'info',
22 enableMocks: false
23 },
24 production: {
25 debug: false,
26 logLevel: 'error',
27 enableMocks: false
28 }
29 };
30
31 return {
32 ...baseConfig,
33 ...environmentConfigs[baseConfig.environment] || environmentConfigs.production
34 };
35 }, []);
36
37 return (
38 <ConfigContext.Provider value={config}>
39 {children}
40 </ConfigContext.Provider>
41 );
42}
43
44export const useConfig = () => {
45 const context = useContext(ConfigContext);
46 if (!context) {
47 throw new Error('useConfig must be used within ConfigProvider');
48 }
49 return context;
50};Un environnement inconnu reçoit la configuration production, qui est la plus sûre : pas de débogage et pas de mocks.
4. Analyse des dépendances
Le maillon le plus faible est souvent le code des autres. Les scripts de package.json lancent un audit des vulnérabilités et un contrôle des licences :
1// Scripts package.json pour la sécurité
2{
3 "scripts": {
4 "audit": "npm audit",
5 "audit:fix": "npm audit fix",
6 "audit:force": "npm audit fix --force",
7 "security:check": "npm audit && npm run license:check",
8 "license:check": "license-checker --summary",
9 "deps:update": "npm-check-updates -u",
10 "deps:check": "npm outdated"
11 },
12 "devDependencies": {
13 "license-checker": "^25.0.1",
14 "npm-check-updates": "^23.1.0",
15 "audit-ci": "^7.1.0"
16 }
17}npm audit compare tes dépendances à une base de vulnérabilités connues. Attention à npm audit fix --force, car il peut monter des versions majeures et casser l'application.
Il vaut la peine de lancer automatiquement les mêmes contrôles à chaque push et à chaque pull request :
1# .github/workflows/security.yml
2name: Security Checks
3
4on: [push, pull_request]
5
6jobs:
7 security:
8 runs-on: ubuntu-latest
9 permissions:
10 contents: read
11 security-events: write # CodeQL envoie les résultats dans l'onglet Security
12
13 steps:
14 - uses: actions/checkout@v7
15
16 - name: Initialize CodeQL
17 uses: github/codeql-action/init@v4
18 with:
19 languages: javascript-typescript
20
21 - name: Setup Node.js
22 uses: actions/setup-node@v7
23 with:
24 node-version: 'lts/*'
25 cache: 'npm'
26
27 - name: Install dependencies
28 run: npm ci
29
30 - name: Run npm audit
31 run: npm audit --audit-level high
32
33 - name: Check for known vulnerabilities
34 run: npx audit-ci --high
35
36 - name: License compliance check
37 run: npm run license:check
38
39 - name: SAST with CodeQL
40 uses: github/codeql-action/analyze@v4
41
42 - name: Dependency Review
43 uses: actions/dependency-review-action@v5
44 if: github.event_name == 'pull_request'Le pipeline s'arrêtera sur une vulnérabilité de niveau high, et dependency-review-action vérifiera les nouvelles dépendances dans la pull request. CodeQL fonctionne en deux étapes : init prépare l'analyse, et analyze analyse le code et envoie les résultats, ce qui demande au job la permission security-events: write. node-version: 'lts/*' choisit la version LTS actuelle de Node.js, et Dependabot peut mettre à jour les versions des actions (package-ecosystem: github-actions).
SecurityMonitor écoute dans le navigateur les événements securitypolicyviolation, c'est-à-dire les violations de la CSP, et les signale au serveur :
1// SecurityMonitor.jsx
2class SecurityMonitor {
3 constructor() {
4 this.violations = [];
5 this.init();
6 }
7
8 init() {
9 // Surveillance des violations de la CSP
10 document.addEventListener('securitypolicyviolation', (e) => {
11 this.reportViolation({
12 type: 'CSP_VIOLATION',
13 directive: e.violatedDirective,
14 blockedURI: e.blockedURI,
15 documentURI: e.documentURI,
16 timestamp: new Date().toISOString()
17 });
18 });
19
20 // Surveillance de console.error pour les problèmes de sécurité potentiels
21 const originalError = console.error;
22 console.error = (...args) => {
23 const message = args.join(' ');
24
25 if (this.isSecurityRelated(message)) {
26 this.reportViolation({
27 type: 'SECURITY_ERROR',
28 message,
29 timestamp: new Date().toISOString()
30 });
31 }
32
33 originalError.apply(console, args);
34 };
35 }
36
37 isSecurityRelated(message) {
38 const securityKeywords = [
39 'unsafe-inline',
40 'unsafe-eval',
41 'xss',
42 'csrf',
43 'cors',
44 'unauthorized',
45 'forbidden'
46 ];
47
48 return securityKeywords.some(keyword =>
49 message.toLowerCase().includes(keyword)
50 );
51 }
52
53 reportViolation(violation) {
54 this.violations.push(violation);
55
56 // Envoi au système de surveillance
57 if (process.env.NODE_ENV === 'production') {
58 fetch('/api/security/violations', {
59 method: 'POST',
60 headers: { 'Content-Type': 'application/json' },
61 body: JSON.stringify(violation)
62 }).catch(error => {
63 console.error('Failed to report security violation:', error);
64 });
65 }
66 }
67
68 getViolations() {
69 return [...this.violations];
70 }
71}
72
73// Hook pour la surveillance de la sécurité
74export function useSecurityMonitor() {
75 const monitor = useRef(new SecurityMonitor());
76
77 const reportCustomViolation = useCallback((violation) => {
78 monitor.current.reportViolation({
79 type: 'CUSTOM_VIOLATION',
80 ...violation,
81 timestamp: new Date().toISOString()
82 });
83 }, []);
84
85 return {
86 reportViolation: reportCustomViolation,
87 getViolations: () => monitor.current.getViolations()
88 };
89}Tu apprendras qu'un script a été bloqué avant même qu'un utilisateur ne le signale. Remplacer console.error est toutefois une intervention globale, donc utilise-la avec prudence.
5. Validation et assainissement des données
InputValidator vérifie les champs obligatoires, la longueur et les motifs, et SecureForm valide les champs pendant la saisie et nettoie les valeurs avant l'envoi :
1// InputValidator.js
2class InputValidator {
3 static patterns = {
4 email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
5 phone: /^\+?[\d\s\-\(\)]+$/,
6 url: /^https?:\/\/.+/,
7 alphanumeric: /^[a-zA-Z0-9]+$/,
8 noSpecialChars: /^[a-zA-Z0-9\s\-_]+$/
9 };
10
11 static validate(value, rules) {
12 const errors = [];
13
14 // Champ obligatoire
15 if (rules.required && (!value || value.trim() === '')) {
16 errors.push('Ce champ est obligatoire');
17 }
18
19 if (!value) return errors;
20
21 // Longueur
22 if (rules.minLength && value.length < rules.minLength) {
23 errors.push(`Nombre minimal de caractères : ${rules.minLength}`);
24 }
25
26 if (rules.maxLength && value.length > rules.maxLength) {
27 errors.push(`Nombre maximal de caractères : ${rules.maxLength}`);
28 }
29
30 // Motif
31 if (rules.pattern && !this.patterns[rules.pattern]?.test(value)) {
32 errors.push(`Format invalide : ${rules.pattern}`);
33 }
34
35 // Règle personnalisée
36 if (rules.custom) {
37 const customError = rules.custom(value);
38 if (customError) errors.push(customError);
39 }
40
41 // Protection contre le XSS
42 if (rules.preventXSS && this.containsPotentialXSS(value)) {
43 errors.push('Le champ contient un contenu potentiellement dangereux');
44 }
45
46 return errors;
47 }
48
49 static containsPotentialXSS(value) {
50 const xssPatterns = [
51 /<script[^>]*>.*?<\/script>/gi,
52 /javascript:/gi,
53 /on\w+\s*=/gi,
54 /<iframe[^>]*>/gi,
55 /<object[^>]*>/gi,
56 /<embed[^>]*>/gi,
57 /eval\s*\(/gi,
58 /expression\s*\(/gi
59 ];
60
61 return xssPatterns.some(pattern => pattern.test(value));
62 }
63
64 static sanitize(value, options = {}) {
65 if (!value) return value;
66
67 let sanitized = value;
68
69 // Supprimer les balises HTML
70 if (options.stripHTML) {
71 sanitized = sanitized.replace(/<[^>]*>/g, '');
72 }
73
74 // Remplacer les caractères spéciaux par des entités HTML
75 if (options.escapeHTML) {
76 sanitized = sanitized
77 .replace(/&/g, '&')
78 .replace(/</g, '<')
79 .replace(/>/g, '>')
80 .replace(/"/g, '"')
81 .replace(/'/g, ''');
82 }
83
84 // Supprimer les octets nuls
85 sanitized = sanitized.replace(/\0/g, '');
86
87 // Supprimer les espaces au début et à la fin
88 if (options.trim !== false) {
89 sanitized = sanitized.trim();
90 }
91
92 return sanitized;
93 }
94}
95
96// Composant SecureForm
97function SecureForm({ onSubmit, children }) {
98 const [formData, setFormData] = useState({});
99 const [errors, setErrors] = useState({});
100 const [isSubmitting, setIsSubmitting] = useState(false);
101
102 const validateField = useCallback((name, value, rules) => {
103 const fieldErrors = InputValidator.validate(value, rules);
104
105 setErrors(prev => ({
106 ...prev,
107 [name]: fieldErrors.length > 0 ? fieldErrors : undefined
108 }));
109
110 return fieldErrors.length === 0;
111 }, []);
112
113 const handleInputChange = useCallback((name, value, rules) => {
114 // Garder le texte brut : React l’échappe au rendu
115 setFormData(prev => ({
116 ...prev,
117 [name]: value
118 }));
119
120 // Valider à chaque modification
121 validateField(name, value, rules);
122 }, [validateField]);
123
124 const handleSubmit = async (e) => {
125 e.preventDefault();
126 setIsSubmitting(true);
127
128 // Valider tous les champs selon les règles stockées dans les enfants
129 let isValid = true;
130 const newErrors = {};
131
132 React.Children.forEach(children, child => {
133 if (!React.isValidElement(child) || !child.props.name) return;
134
135 const fieldName = child.props.name;
136 const fieldErrors = InputValidator.validate(formData[fieldName], child.props.validationRules || {});
137
138 if (fieldErrors.length > 0) {
139 newErrors[fieldName] = fieldErrors;
140 isValid = false;
141 }
142 });
143
144 setErrors(newErrors);
145
146 if (isValid) {
147 // Nettoyer les valeurs et supprimer les octets nuls juste avant l’envoi
148 const cleanData = Object.fromEntries(
149 Object.entries(formData).map(([key, value]) => [key, InputValidator.sanitize(value)])
150 );
151
152 try {
153 await onSubmit(cleanData);
154 } catch (error) {
155 console.error('Échec de l’envoi du formulaire :', error);
156 }
157 }
158
159 setIsSubmitting(false);
160 };
161
162 return (
163 <form onSubmit={handleSubmit}>
164 {React.Children.map(children, child => {
165 if (React.isValidElement(child) && child.props.name) {
166 return React.cloneElement(child, {
167 value: formData[child.props.name] || '',
168 onChange: (e) => handleInputChange(
169 child.props.name,
170 e.target.value,
171 child.props.validationRules
172 ),
173 error: errors[child.props.name],
174 disabled: isSubmitting
175 });
176 }
177 return child;
178 })}
179 </form>
180 );
181}SecureForm garde le texte brut dans l'état, car React l'échappe au rendu, et échapper les entités à chaque frappe abîmerait le texte saisi : & deviendrait &, puis &amp; après le caractère suivant. sanitize ne nettoie les valeurs qu'avant l'envoi, et escapeHTML ne sert que lorsque le texte va dans du HTML hors de React. La validation dans le navigateur améliore le confort, mais ne sécurise rien, car un attaquant enverra la requête en contournant le formulaire. Le serveur doit vérifier les données une nouvelle fois, et considère les expressions de containsPotentialXSS comme un signal, pas comme un bouclier.
6. OWASP Top 10
Pour finir, une liste de contrôle basée sur l'OWASP Top 10:2025, le classement le plus connu des risques des applications web. Le code montre des catégories choisies, dans l'ordre du classement :
1// Liste de contrôle OWASP Top 10:2025 (catégories choisies)
2
3// A01:2025 Broken Access Control - l'interface masque les vues, mais c'est le serveur qui vérifie les droits
4function ProtectedRoute({ children, requiredRole, requiredPermissions }) {
5 const { user, hasRole, hasPermissions } = useAuth();
6
7 if (!user) {
8 return <Navigate to="/login" replace />;
9 }
10
11 if (requiredRole && !hasRole(requiredRole)) {
12 return <div>Accès refusé : rôle insuffisant</div>;
13 }
14
15 if (requiredPermissions && !hasPermissions(requiredPermissions)) {
16 return <div>Accès refusé : permissions insuffisantes</div>;
17 }
18
19 return children;
20}
21
22// A02:2025 Security Misconfiguration - en-têtes, CORS et parseur XML (XXE)
23const SecurityConfig = {
24 securityHeaders: {
25 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
26 'X-Content-Type-Options': 'nosniff',
27 'X-Frame-Options': 'DENY',
28 'X-XSS-Protection': '0',
29 'Referrer-Policy': 'strict-origin-when-cross-origin'
30 },
31
32 corsConfig: {
33 origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
34 credentials: true,
35 optionsSuccessStatus: 200
36 },
37
38 // Le DOMParser du navigateur ne charge pas les entités XML externes
39 parseXMLSafely: (xmlString) => {
40 const doc = new DOMParser().parseFromString(xmlString, 'text/xml');
41
42 if (doc.querySelector('parsererror')) {
43 throw new Error('Format XML invalide');
44 }
45
46 return doc;
47 }
48};
49
50// A03:2025 Software Supply Chain Failures - npm ci, npm audit et dependency review de la section 4
51
52// A04:2025 Cryptographic Failures - hasard issu de Web Crypto, chiffrement des données sur le serveur
53const CryptoPractices = {
54 // Token issu d'un générateur cryptographiquement sûr, jamais de Math.random()
55 randomToken: (bytes = 32) => {
56 const values = crypto.getRandomValues(new Uint8Array(bytes));
57 return Array.from(values, (b) => b.toString(16).padStart(2, '0')).join('');
58 }
59};
60
61// A05:2025 Injection - données séparées de la requête, jamais concaténées au texte SQL
62const InjectionPrevention = {
63 // Utilise toujours des requêtes préparées (code backend)
64 safeQuery: (query, params) => {
65 return db.prepare(query).run(params);
66 },
67
68 // Validation des paramètres
69 validateParams: (params, schema) => {
70 return schema.validate(params);
71 }
72};
73
74// A07:2025 Authentication Failures - règles de mot de passe selon le NIST SP 800-63B
75const AuthenticationSecurity = {
76 passwordPolicy: {
77 minLength: 15, // 8 si le compte a la MFA
78 maxLength: 64, // autorise les longues phrases de passe
79 checkBreachedPasswords: true, // refuse les mots de passe divulgués et les plus courants
80 requireCharacterMix: false, // sans imposer de majuscules ni de caractères spéciaux
81 forcePeriodicChange: false // changement uniquement en cas de fuite suspectée
82 },
83
84 // Limiter les tentatives de connexion échouées
85 loginAttempts: {
86 maxAttempts: 5,
87 lockoutDuration: 15 * 60 * 1000 // 15 minutes
88 },
89
90 // Session dans un cookie
91 sessionConfig: {
92 httpOnly: true,
93 secure: true,
94 sameSite: 'strict',
95 maxAge: 30 * 60 * 1000 // 30 minutes
96 }
97};La liste complète de l'OWASP Top 10:2025 est A01 Broken Access Control, A02 Security Misconfiguration, A03 Software Supply Chain Failures, A04 Cryptographic Failures, A05 Injection, A06 Insecure Design, A07 Authentication Failures, A08 Software or Data Integrity Failures, A09 Security Logging and Alerting Failures et A10 Mishandling of Exceptional Conditions. Le XSS de la première section relève d'A05, et SecurityMonitor de la section sur les dépendances est un exemple d'A09. On règle X-XSS-Protection sur 0, car l'ancien filtre des navigateurs pouvait créer de nouvelles failles, et MDN recommande la CSP à sa place. Une clé de chiffrement dans le code frontend n'a rien de secret, donc le chiffrement des données sensibles revient au serveur, et le navigateur prend ses valeurs aléatoires dans la Web Crypto API. Les règles de mot de passe suivent le NIST SP 800-63B (révision 4 de 2025), auquel se réfère l'OWASP : ce qui compte, c'est la longueur et le refus des mots de passe divulgués, et le NIST interdit d'imposer des caractères spéciaux ou un changement périodique du mot de passe.
La sécurité couvre toutes les couches, du client au serveur, et exige des audits réguliers et des dépendances à jour. Je te recommande de commencer par trois étapes : DOMPurify pour chaque morceau de HTML brut, des cookies httpOnly et un audit automatique dans la CI. Elles reviendront toutes dans l'exercice Security Operations Center.
Retiens : chaque couche est un bouclier distinct du vaisseau - React échappe, la CSP filtre, et le serveur vérifie tout encore une fois.
Code de cette leçon : App.jsx
1import React, { useState, useRef } from 'react';
2
3// Démonstration des bonnes pratiques de sécurité en React
4// Protection XSS, assainissement du HTML et validation des saisies
5
6function XSSDemo() {
7 const [userInput, setUserInput] = useState('');
8 const [messages, setMessages] = useState([
9 { id: 1, text: 'Bienvenue à bord de la station !', author: 'Capitaine' },
10 { id: 2, text: 'Systèmes de défense actifs.', author: 'Officier' },
11 ]);
12 const nextId = useRef(3);
13
14 // React échappe automatiquement le contenu JSX - protection contre le XSS
15 const maliciousInput = '<script>alert("hack!")<\/script><img src=x onerror=alert(1)>';
16
17 const addMessage = () => {
18 if (userInput.trim()) {
19 setMessages(prev => [...prev, {
20 id: nextId.current++,
21 text: userInput, // React échappe ces données en toute sécurité
22 author: 'Utilisateur',
23 }]);
24 setUserInput('');
25 }
26 };
27
28 return (
29 <div style={styles.panel}>
30 <h2 style={styles.heading}>1. Protection contre les XSS</h2>
31
32 <div style={{ marginBottom: '16px' }}>
33 <h4 style={styles.subheading}>Saisie malveillante (échappée automatiquement) :</h4>
34 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', color: '#ff6b6b', fontFamily: 'monospace', fontSize: '13px', wordBreak: 'break-all' }}>
35 {maliciousInput}
36 </div>
37 <p style={{ color: '#00ff88', fontSize: '13px' }}>
38 React affiche ceci comme du texte, pas comme du HTML - le script ne s’exécutera pas !
39 </p>
40 </div>
41
42 <div>
43 <h4 style={styles.subheading}>Chat sécurisé de la station spatiale :</h4>
44 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
45 {messages.map(msg => (
46 <div key={msg.id} style={{ marginBottom: '8px' }}>
47 <span style={{ color: '#00d4ff', fontWeight: 'bold' }}>{msg.author}: </span>
48 <span style={{ color: '#e0e1dd' }}>{msg.text}</span>
49 </div>
50 ))}
51 </div>
52 <div style={{ display: 'flex', gap: '8px' }}>
53 <input
54 value={userInput}
55 onChange={e => setUserInput(e.target.value)}
56 onKeyDown={e => e.key === 'Enter' && addMessage()}
57 placeholder="Saisis un message (essaie du HTML/script)..."
58 aria-label="Saisis un message (essaie du HTML/script)..."
59 style={{ flex: 1, minWidth: 0, padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd' }}
60 />
61 <button onClick={addMessage} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: '#00d4ff', color: '#0d1b2a', fontWeight: 'bold', cursor: 'pointer' }}>
62 Envoyer
63 </button>
64 </div>
65 </div>
66 </div>
67 );
68}
69
70function DangerousHTMLDemo() {
71 const [showComparison, setShowComparison] = useState(false);
72
73 const safeHTML = '<strong>Message important</strong> depuis la station <em>spatiale</em>';
74 const unsafeHTML = '<img src=x onerror="alert(1)"><b>HTML dangereux</b>';
75
76 // Filtre simplifié : garde quelques balises sans aucun attribut (onerror, onclick compris)
77 // et supprime le reste. Uniquement pour la démonstration - dans une appli, utilise DOMPurify.sanitize()
78 const sanitize = (html) => {
79 const allowedTags = ['b', 'i', 'em', 'strong', 'p', 'br', 'span'];
80 return html.replace(/<(\/?)([a-zA-Z]+)[^>]*>/g, (match, slash, tag) =>
81 allowedTags.includes(tag.toLowerCase()) ? `<${slash}${tag.toLowerCase()}>` : ''
82 );
83 };
84
85 return (
86 <div style={{ ...styles.panel, marginTop: '16px' }}>
87 <h2 style={styles.heading}>2. dangerouslySetInnerHTML</h2>
88
89 <div style={{ display: 'grid', gap: '12px' }}>
90 <div style={styles.box}>
91 <h4 style={{ color: '#ff6b6b', margin: '0 0 8px' }}>Utilisation dangereuse :</h4>
92 <code style={{ color: '#778da9', fontSize: '13px' }}>
93 {'dangerouslySetInnerHTML={{ __html: userInput }}'}
94 </code>
95 <p style={{ color: '#ff6b6b', fontSize: '13px', margin: '8px 0 0' }}>
96 Ne l’utilise jamais avec des données non fiables !
97 </p>
98 </div>
99
100 <div style={styles.box}>
101 <h4 style={{ color: '#00ff88', margin: '0 0 8px' }}>Sûr - après assainissement :</h4>
102 <div style={{ color: '#e0e1dd', marginBottom: '8px' }} dangerouslySetInnerHTML={{ __html: sanitize(safeHTML) }} />
103 <code style={{ color: '#778da9', fontSize: '12px', display: 'block' }}>
104 {'dangerouslySetInnerHTML={{ __html: sanitize(html) }}'}
105 </code>
106 <p style={{ color: '#ffd166', fontSize: '12px', margin: '8px 0 0' }}>
107 La fonction sanitize de cet exemple est une simplification. Dans une vraie appli, utilise DOMPurify.sanitize(), qui connaît les pièges du parseur HTML.
108 </p>
109 </div>
110 </div>
111
112 <button
113 onClick={() => setShowComparison(visible => !visible)}
114 style={{ marginTop: '12px', padding: '8px 16px', borderRadius: '8px', border: '1px solid #415a77', background: 'transparent', color: '#778da9', cursor: 'pointer' }}
115 >
116 {showComparison ? 'Masquer la comparaison de l’assainissement' : 'Afficher la comparaison de l’assainissement'}
117 </button>
118
119 {showComparison && (
120 <div style={{ ...styles.box, marginTop: '12px' }}>
121 <p style={{ color: '#778da9', margin: '0 0 4px', fontSize: '13px', wordBreak: 'break-all' }}>Avant : {unsafeHTML}</p>
122 <p style={{ color: '#00ff88', margin: 0, fontSize: '13px', wordBreak: 'break-all' }}>Après assainissement : {sanitize(unsafeHTML)}</p>
123 </div>
124 )}
125 </div>
126 );
127}
128
129// Force du mot de passe selon le NIST SP 800-63B : la longueur et le refus des mots de passe courants comptent, pas les caractères spéciaux imposés
130const COMMON_PASSWORDS = ['123456', 'password', 'qwerty', 'motdepasse', 'admin123'];
131
132function passwordStrength(password) {
133 if (COMMON_PASSWORDS.includes(password.toLowerCase())) return 0;
134 if (password.length >= 20) return 4;
135 if (password.length >= 15) return 3;
136 if (password.length >= 12) return 2;
137 if (password.length >= 8) return 1;
138 return 0;
139}
140
141function InputValidationDemo() {
142 const [email, setEmail] = useState('');
143 const [emailValid, setEmailValid] = useState(null);
144 const [password, setPassword] = useState('');
145
146 const validateEmail = (value) => {
147 setEmail(value);
148 if (!value) { setEmailValid(null); return; }
149 const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
150 setEmailValid(regex.test(value));
151 };
152
153 const strength = passwordStrength(password);
154 const isCommon = COMMON_PASSWORDS.includes(password.toLowerCase());
155 const strengthLabels = ['Très faible', 'Faible', 'Moyen', 'Bon', 'Fort'];
156 const strengthColors = ['#ff6b6b', '#ff6b6b', '#ffaa00', '#00d4ff', '#00ff88'];
157
158 return (
159 <div style={{ ...styles.panel, marginTop: '16px' }}>
160 <h2 style={styles.heading}>3. Validation des données saisies</h2>
161
162 <div style={{ marginBottom: '16px' }}>
163 <label htmlFor="crew-email" style={styles.label}>E-mail de l’équipage :</label>
164 <input
165 id="crew-email"
166 value={email}
167 onChange={e => validateEmail(e.target.value)}
168 placeholder="pilote@station.space"
169 style={{ ...styles.input, borderColor: emailValid === null ? '#415a77' : emailValid ? '#00ff88' : '#ff6b6b' }}
170 />
171 {emailValid !== null && (
172 <p style={{ color: emailValid ? '#00ff88' : '#ff6b6b', fontSize: '13px', margin: '4px 0 0' }}>
173 {emailValid ? 'Format d’e-mail valide' : 'Format d’e-mail invalide'}
174 </p>
175 )}
176 </div>
177
178 <div>
179 <label htmlFor="crew-password" style={styles.label}>Mot de passe d’accès :</label>
180 <input
181 id="crew-password"
182 type="password"
183 value={password}
184 onChange={e => setPassword(e.target.value)}
185 placeholder="Au moins 15 caractères, idéalement plusieurs mots"
186 style={styles.input}
187 />
188 {password && (
189 <div style={{ marginTop: '8px' }}>
190 <div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
191 {[1, 2, 3, 4].map(level => (
192 <div key={level} style={{ flex: 1, height: '4px', borderRadius: '2px', background: level <= strength ? strengthColors[strength] : '#415a77' }} />
193 ))}
194 </div>
195 <p style={{ color: strengthColors[strength], fontSize: '13px', margin: 0 }}>
196 {isCommon ? 'Ce mot de passe figure parmi les plus courants - choisis-en un autre' : `Force du mot de passe : ${strengthLabels[strength]} (longueur : ${password.length})`}
197 </p>
198 </div>
199 )}
200 <p style={{ color: '#778da9', fontSize: '12px', margin: '8px 0 0' }}>Une longue phrase de plusieurs mots est plus forte qu’un mot de passe court avec des caractères spéciaux.</p>
201 </div>
202 </div>
203 );
204}
205
206const styles = {
207 panel: { background: '#1b2838', padding: '20px', borderRadius: '12px', border: '1px solid #415a77' },
208 heading: { color: '#00d4ff', marginTop: 0 },
209 subheading: { color: '#778da9' },
210 box: { background: '#0d1b2a', padding: '12px', borderRadius: '8px' },
211 label: { color: '#778da9', display: 'block', marginBottom: '4px' },
212 input: { width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd', boxSizing: 'border-box' },
213};
214
215export default function App() {
216 return (
217 <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'monospace' }}>
218 <div style={{ maxWidth: '650px', margin: '0 auto' }}>
219 <h1 style={{ color: '#00d4ff', textAlign: 'center' }}>Sécurité en React</h1>
220 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px' }}>
221 Systèmes de défense de la station spatiale - pratiques de sécurité
222 </p>
223 <XSSDemo />
224 <DangerousHTMLDemo />
225 <InputValidationDemo />
226 </div>
227 </div>
228 );
229}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment React protège-t-il par défaut contre les attaques XSS (Cross-Site Scripting) ?
2. Dans quel cas DOIS-tu assainir les données saisies par l'utilisateur dans React à l'aide d'une bibliothèque comme DOMPurify ?
Tâches pratiques dans le jeu
- Tri horizontal
Compose la syntaxe d'utilisation de DOMPurify pour afficher du HTML en toute sécurité dans React :
- Éditeur de code
Sécurise le panneau des transmissions de vaisseaux inconnus. React échappe tout seul le texte des messages, car il arrive dans le JSX entre accolades. ___BLANK1___ : isSafeUrl renvoie true uniquement pour les protocoles http:, https: et mailto: (false pour javascript:, data: et tout autre). ___BLANK2___ : remplis le tableau bytes d'octets aléatoires issus de crypto.getRandomValues, cryptographiquement sûr (jamais Math.random), pour obtenir un jeton CSRF de 32 caractères. ___BLANK3___ : un lien qui s'ouvre dans un nouvel onglet reçoit rel avec les valeurs noopener et noreferrer. Dans l'aperçu, le lien du vaisseau inconnu doit être bloqué et son texte affiché comme du texte simple.