Cours JavaScript et React · Module 10: Écosystème et avenir de React

Pratiques de sécurité complètes dans React

19 min de lecture
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_here

Tout 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, '&amp;')
78        .replace(/</g, '&lt;')
79        .replace(/>/g, '&gt;')
80        .replace(/"/g, '&quot;')
81        .replace(/'/g, '&#x27;');
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 &amp;, puis &amp;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. 1. Comment React protège-t-il par défaut contre les attaques XSS (Cross-Site Scripting) ?

  2. 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.

Articles utiles