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 decredentials: trueavec* - 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)