Explorer
KNOW-PAT-061

API Express avec JWT - Hardening et anti-patterns critiques

Domaine
cybersecu
Type
pattern
Priorité
P2

Parent : [[INDEX-CYBERSECU]]

API Express avec JWT - Hardening et anti-patterns critiques

Problème

L'archive Ygg contient une API Express complète (01_SOURCE_CODE/02b_express_api/) avec authentification JWT, système de téléchargement restrictif, et gestion des torrents. Plusieurs failles critiques sont présentes : secrets par défaut prévisibles, CORS ouvert, secret JWT réutilisé pour les sessions, et absence de validation stricte des inputs.

Contexte archive Ygg

  • Fichier : 01_SOURCE_CODE/02b_express_api/server.js
  • Fichier : 01_SOURCE_CODE/02b_express_api/config.js
  • Fichier : 01_SOURCE_CODE/02b_express_api/auth.middleware.js
  • Fichier : 01_SOURCE_CODE/02b_express_api/dotenv.txt
  • Fichier : 04_CREDENTIALS/04e_configs_secrets/ygg-api.env
  • Stack : Node.js + Express + JWT + MySQL

Failles identifiées

1. JWT_SECRET prévisible (config.js ligne 2)

module.exports = {
  jwtSecret: process.env.JWT_SECRET || 'votre_cle_secrete_jwt',  // ❌ Fallback prévisible
  secretKey: process.env.SECRET_KEY || 'votre_cle_secrete_hash', // ❌ Fallback prévisible
  corsOrigin: process.env.CORS_ORIGIN || '*',                      // ❌ CORS ouvert
};

2. JWT_SECRET réutilisé pour les sessions Express (server.js ligne 25)

app.use(session({
  secret: config.jwtSecret,  // ❌ Même secret pour JWT ET sessions
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: config.env === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000
  }
}));

3. CORS ouvert (server.js ligne 18)

app.use(cors({
  origin: config.corsOrigin,  // Par défaut '*' = n'importe quel site
  credentials: true           // ❌ + credentials = vol de session
}));

4. DB root sans mot de passe (dotenv.txt ligne 3)

DB_USER=root
DB_PASSWORD=  // ❌ Root sans mot de passe

5. Announcer signature key hardcodée (config.js ligne 12)

announceSignatureKey: process.env.ANNOUNCE_SIGNATURE_KEY || 'xf7mTOvmX2tHeXDz2CkkmJddUdia8wu6'

Solution protectif — API Express sécurisée

1. Configuration JWT sécurisée

// config.js — Configuration sécurisée
const crypto = require('crypto');

function requireEnv(name, minLength = 32) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Variable d'environnement requise: ${name}`);
  }
  if (value.length < minLength) {
    throw new Error(`${name} doit faire au moins ${minLength} caractères`);
  }
  // Détecter les valeurs par défaut dangereuses
  const defaults = ['votre_cle', 'secret', 'password', '123456', 'changez'];
  if (defaults.some(d => value.toLowerCase().includes(d))) {
    throw new Error(`${name} contient une valeur par défaut dangereuse`);
  }
  return value;
}

module.exports = {
  jwtSecret: requireEnv('JWT_SECRET'),
  jwtExpiresIn: process.env.JWT_EXPIRES_IN || '15m', // 15 min max
  refreshSecret: requireEnv('JWT_REFRESH_SECRET'),    // Secret différent
  secretKey: requireEnv('SECRET_KEY'),
  
  // CORS strict
  corsOrigin: (() => {
    const origins = process.env.CORS_ORIGIN?.split(',') || [];
    if (origins.length === 0) {
      throw new Error('CORS_ORIGIN requis (ex: https://monsite.com)');
    }
    if (origins.includes('*')) {
      throw new Error('CORS_ORIGIN ne peut pas être * avec credentials');
    }
    return origins;
  })(),
  
  port: process.env.PORT || 3000,
  env: process.env.NODE_ENV || 'development',
  
  // Rate limiting
  rateLimitWindow: 15 * 60 * 1000,
  rateLimitMax: process.env.NODE_ENV === 'production' ? 30 : 100,
};

2. Sessions séparées du JWT

// server.js — Sessions indépendantes du JWT
const crypto = require('crypto');

// Générer un secret de session aléatoire au démarrage (OU depuis env)
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(64).toString('hex');

app.use(session({
  secret: sessionSecret,  // ✅ Secret DIFFÉRENT du JWT
  resave: false,
  saveUninitialized: false,
  name: 'sessionId',      // ✅ Pas le nom par défaut 'connect.sid'
  cookie: {
    secure: true,         // ✅ HTTPS obligatoire
    httpOnly: true,
    sameSite: 'strict',   // ✅ Protection CSRF
    maxAge: 60 * 60 * 1000 // ✅ 1 heure max
  },
  store: new RedisStore({ client: redisClient }) // ✅ Redis store
}));

3. Auth middleware durci

// auth.middleware.js — JWT avec vérifications strictes
const jwt = require('jsonwebtoken');
const config = require('../config/config');

const authenticateToken = async (req, res, next) => {
  try {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
      return res.status(401).json({
        success: false,
        message: 'Token d\'authentification manquant'
      });
    }

    // Vérifier le format du token
    if (!/^Bearer\s+[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(authHeader)) {
      return res.status(401).json({
        success: false,
        message: 'Format de token invalide'
      });
    }

    // Vérifier le token
    jwt.verify(token, config.jwtSecret, {
      algorithms: ['HS256'],      // ✅ Algorithmes autorisés explicitement
      issuer: 'api.monsite.com',  // ✅ Vérifier l'émetteur
      audience: 'api.monsite.com', // ✅ Vérifier l'audience
      maxAge: '15m'               // ✅ Durée max
    }, async (err, decoded) => {
      if (err) {
        if (err.name === 'TokenExpiredError') {
          return res.status(401).json({
            success: false,
            message: 'Token expiré',
            expiredAt: err.expiredAt
          });
        }
        return res.status(403).json({
          success: false,
          message: 'Token invalide'
        });
      }

      // Vérifier l'utilisateur en DB (revocation check)
      const [users] = await db.query(
        'SELECT id, nickname, rank, email, token_version FROM users WHERE id = ? AND is_valid = 1',
        [decoded.userId]
      );

      if (users.length === 0) {
        return res.status(403).json({
          success: false,
          message: 'Utilisateur non trouvé ou compte invalide'
        });
      }

      // Vérifier la version du token (revocation)
      if (decoded.tokenVersion !== users[0].token_version) {
        return res.status(403).json({
          success: false,
          message: 'Token révoqué'
        });
      }

      req.user = {
        id: users[0].id,
        nickname: users[0].nickname,
        rank: users[0].rank,
        email: users[0].email
      };

      next();
    });
  } catch (error) {
    console.error('Erreur auth:', error);
    return res.status(500).json({
      success: false,
      message: 'Erreur serveur'
    });
  }
};

4. Rate limiting adaptatif

// rateLimit.js — Rate limiting intelligent
const rateLimit = require('express-rate-limit');

// Rate limit strict pour l'auth
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true,
  keyGenerator: (req) => req.ip,
  handler: (req, res) => {
    res.status(429).json({
      success: false,
      message: 'Trop de tentatives. Réessayez dans 15 minutes.'
    });
  }
});

// Rate limit pour l'API générale
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: (req) => {
    // Plus permissif pour les utilisateurs authentifiés
    if (req.user) return 100;
    return 30; // Strict pour les anonymes
  },
  keyGenerator: (req) => req.user?.id || req.ip,
});

module.exports = { authLimiter, apiLimiter };

5. Refresh token rotation

// refresh.js — Rotation des refresh tokens
async function refreshAccessToken(refreshToken) {
  // Vérifier le refresh token
  const decoded = jwt.verify(refreshToken, config.refreshSecret);
  
  // Vérifier en DB si le token n'est pas révoqué
  const [tokens] = await db.query(
    'SELECT * FROM refresh_tokens WHERE token = ? AND revoked = 0',
    [refreshToken]
  );
  
  if (tokens.length === 0) {
    throw new Error('Refresh token invalide');
  }
  
  // Vérifier l'expiration
  if (new Date(tokens[0].expires_at) < new Date()) {
    throw new Error('Refresh token expiré');
  }
  
  // Révoquer l'ancien refresh token
  await db.query(
    'UPDATE refresh_tokens SET revoked = 1 WHERE token = ?',
    [refreshToken]
  );
  
  // Générer un nouveau token
  const newAccessToken = generateAccessToken(decoded.userId);
  const newRefreshToken = generateRefreshToken(decoded.userId);
  
  // Stocker le nouveau refresh token
  await db.query(
    'INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
    [newRefreshToken, decoded.userId]
  );
  
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Checklist de validation

  • JWT_SECRET ≥ 64 caractères, aléatoire, en variable d'environnement
  • JWT_REFRESH_SECRET différent du JWT_SECRET
  • SESSION_SECRET différent du JWT_SECRET
  • Pas de fallback prévisible dans le code
  • CORS strict (pas *), pas de credentials: true avec *
  • Rate limiting sur /api/auth/* (max 5/15min)
  • Validation de l'algorithme JWT (whitelist HS256)
  • Vérification issuer/audience
  • Revocation check en DB (token_version)
  • Refresh token rotation
  • Cookie session : secure, httpOnly, sameSite=strict
  • DB credentials non-root avec mot de passe fort
  • Pas de secrets hardcodés (announceSignatureKey, etc.)
  • UnhandledRejection log + graceful shutdown (pas exit(1))

Anti-pattern associé

  • KNOW-ANT-010 — Default JWT secrets and open CORS

Références

  • Archive Ygg : 01_SOURCE_CODE/02b_express_api/
  • Archive Ygg : 04_CREDENTIALS/04e_configs_secrets/ygg-api.env
  • OWASP : JWT Security Best Practices
  • RFC 7519 : JSON Web Token (JWT)