Update v1.2.0-beta - Dynamic context menu & permissions
All checks were successful
continuous-integration/drone/push Build is passing

 New Features:
- Dynamic permission-based context menus for files and folders
- Support for collaborative folder access control
- Upload to specific folders including shared folders
- Changelog modal for version updates
- Improved dark mode synchronization

🐛 Bug Fixes:
- Fixed context menu displaying incorrect options
- Fixed CSS !important override preventing dynamic menu behavior
- Fixed folder collaboration permission checks
- Fixed breadcrumb navigation with empty segments
- Fixed "Premature close" error loop in attachments
- Fixed missing user variable in admin routes
- Fixed avatar loading COEP policy issues

🔒 Security:
- Added security middleware (CSRF, rate limiting, input validation)
- Fixed collaboration folder access validation
- Improved shared folder permission handling

🎨 UI/UX Improvements:
- Removed Actions column from folder view
- Context menu now properly hides/shows based on permissions
- Better visual feedback for collaborative folders
- Improved upload flow with inline modals

🧹 Code Quality:
- Added collaboration data to folder routes
- Refactored context menu logic for better maintainability
- Added debug logging for troubleshooting
- Improved file upload handling with chunking support
This commit is contained in:
2025-10-25 23:55:51 +02:00
parent 58b57fbb84
commit 2df1b28962
33 changed files with 6275 additions and 1462 deletions

View File

@@ -0,0 +1,170 @@
/**
* Middleware de protection CSRF (Cross-Site Request Forgery)
* Génère et valide des tokens CSRF pour les requêtes sensibles
*/
const crypto = require('crypto');
const { logger } = require('../config/logs');
// Stockage en mémoire des tokens CSRF (en production, utiliser Redis ou une base de données)
const csrfTokens = new Map();
// Durée de vie d'un token CSRF (30 minutes)
const TOKEN_LIFETIME = 30 * 60 * 1000;
/**
* Génère un token CSRF pour une session
*/
const generateCsrfToken = (sessionId) => {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + TOKEN_LIFETIME;
csrfTokens.set(sessionId, {
token,
expiresAt
});
// Nettoyer les tokens expirés toutes les 5 minutes
setInterval(() => {
const now = Date.now();
for (const [id, data] of csrfTokens.entries()) {
if (data.expiresAt < now) {
csrfTokens.delete(id);
}
}
}, 5 * 60 * 1000);
return token;
};
/**
* Valide un token CSRF
*/
const validateCsrfToken = (sessionId, token) => {
const storedData = csrfTokens.get(sessionId);
if (!storedData) {
return false;
}
if (storedData.expiresAt < Date.now()) {
csrfTokens.delete(sessionId);
return false;
}
return storedData.token === token;
};
/**
* Middleware pour ajouter le token CSRF à la requête
*/
const csrfTokenMiddleware = (req, res, next) => {
// Générer un ID de session si nécessaire
if (!req.session) {
return next();
}
const sessionId = req.session.id || req.sessionID;
// Générer ou récupérer le token CSRF
let csrfToken = null;
const storedData = csrfTokens.get(sessionId);
if (storedData && storedData.expiresAt > Date.now()) {
csrfToken = storedData.token;
} else {
csrfToken = generateCsrfToken(sessionId);
}
// Ajouter le token aux locals pour l'utiliser dans les vues
res.locals.csrfToken = csrfToken;
// Ajouter une méthode helper
req.csrfToken = () => csrfToken;
next();
};
/**
* Middleware de validation CSRF pour les méthodes POST, PUT, DELETE
*/
const csrfProtectionMiddleware = (req, res, next) => {
// Ignorer les requêtes GET, HEAD, OPTIONS
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Ignorer certaines routes (API publiques, webhooks, etc.)
const exemptPaths = [
'/auth/activedirectory/callback',
'/auth/discord/callback',
'/api/webhook'
];
if (exemptPaths.some(path => req.path.startsWith(path))) {
return next();
}
// Vérifier si la session existe
if (!req.session) {
logger.warn(`CSRF protection: No session found for ${req.method} ${req.path} from ${req.ip}`);
return res.status(403).json({
error: 'Session invalide',
code: 'NO_SESSION'
});
}
const sessionId = req.session.id || req.sessionID;
// Récupérer le token CSRF de la requête
const token = req.body._csrf ||
req.query._csrf ||
req.headers['x-csrf-token'] ||
req.headers['csrf-token'];
if (!token) {
logger.warn(`CSRF protection: No token provided for ${req.method} ${req.path} from ${req.ip}`);
return res.status(403).json({
error: 'Token CSRF manquant',
code: 'CSRF_TOKEN_MISSING'
});
}
// Valider le token
if (!validateCsrfToken(sessionId, token)) {
logger.warn(`CSRF protection: Invalid token for ${req.method} ${req.path} from ${req.ip}`);
return res.status(403).json({
error: 'Token CSRF invalide ou expiré',
code: 'CSRF_TOKEN_INVALID'
});
}
next();
};
/**
* Route pour obtenir un nouveau token CSRF
*/
const getCsrfToken = (req, res) => {
const sessionId = req.session?.id || req.sessionID;
if (!sessionId) {
return res.status(400).json({
error: 'Session invalide'
});
}
const token = generateCsrfToken(sessionId);
res.json({
csrfToken: token
});
};
module.exports = {
csrfTokenMiddleware,
csrfProtectionMiddleware,
getCsrfToken,
generateCsrfToken,
validateCsrfToken
};

View File

@@ -0,0 +1,202 @@
/**
* Middleware de validation et d'assainissement des entrées utilisateur
* Protège contre les injections SQL, XSS, et autres attaques
*/
const { logger } = require('../config/logs');
/**
* Nettoie une chaîne de caractères pour prévenir les injections
*/
const sanitizeString = (str) => {
if (typeof str !== 'string') return str;
// Supprime les caractères null bytes
str = str.replace(/\0/g, '');
// Encode les caractères spéciaux HTML
str = str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
return str.trim();
};
/**
* Valide un nom de fichier/dossier
*/
const validateFileName = (filename) => {
if (!filename || typeof filename !== 'string') {
return { valid: false, error: 'Nom de fichier invalide' };
}
// Limite de longueur
if (filename.length > 255) {
return { valid: false, error: 'Nom de fichier trop long (max 255 caractères)' };
}
// Caractères interdits dans les noms de fichiers Windows/Linux
const forbiddenChars = /[<>:"\/\\|?*\x00-\x1f]/g;
if (forbiddenChars.test(filename)) {
return { valid: false, error: 'Caractères interdits dans le nom de fichier' };
}
// Noms réservés Windows
const reservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
if (reservedNames.test(filename.split('.')[0])) {
return { valid: false, error: 'Nom de fichier réservé' };
}
// Vérifier les tentatives de traversée de répertoire
if (filename.includes('..') || filename.includes('./') || filename.includes('.\\')) {
return { valid: false, error: 'Tentative de traversée de répertoire détectée' };
}
return { valid: true };
};
/**
* Valide un chemin de dossier
*/
const validatePath = (path) => {
if (!path || typeof path !== 'string') {
return { valid: false, error: 'Chemin invalide' };
}
// Vérifier les tentatives de traversée de répertoire
if (path.includes('..') || path.includes('~')) {
return { valid: false, error: 'Tentative de traversée de répertoire détectée' };
}
// Vérifier les chemins absolus non autorisés
if (path.startsWith('/') || /^[A-Za-z]:/.test(path)) {
return { valid: false, error: 'Chemin absolu non autorisé' };
}
return { valid: true };
};
/**
* Valide un ID utilisateur
*/
const validateUserId = (userId) => {
if (!userId) {
return { valid: false, error: 'ID utilisateur manquant' };
}
// Accepte les formats Discord (snowflake) ou alphanumériques
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(userId)) {
return { valid: false, error: 'Format d\'ID utilisateur invalide' };
}
return { valid: true };
};
/**
* Valide une adresse email
*/
const validateEmail = (email) => {
if (!email || typeof email !== 'string') {
return { valid: false, error: 'Email invalide' };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { valid: false, error: 'Format d\'email invalide' };
}
if (email.length > 254) {
return { valid: false, error: 'Email trop long' };
}
return { valid: true };
};
/**
* Nettoie récursivement un objet
*/
const sanitizeObject = (obj) => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
return sanitizeString(obj);
}
if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item));
}
if (typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
}
return sanitized;
}
return obj;
};
/**
* Middleware principal de validation
*/
const inputValidationMiddleware = (req, res, next) => {
try {
// Log des requêtes suspectes
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/\.\.\//,
/union\s+select/i,
/insert\s+into/i,
/delete\s+from/i,
/drop\s+table/i,
/exec\s*\(/i,
/eval\s*\(/i
];
const checkSuspicious = (value) => {
if (typeof value === 'string') {
return suspiciousPatterns.some(pattern => pattern.test(value));
}
return false;
};
// Vérifier le body
if (req.body && typeof req.body === 'object') {
const bodyStr = JSON.stringify(req.body);
if (suspiciousPatterns.some(pattern => pattern.test(bodyStr))) {
logger.warn(`Suspicious input detected in request body from ${req.ip}: ${req.path}`);
}
}
// Vérifier les query params
if (req.query && typeof req.query === 'object') {
const queryStr = JSON.stringify(req.query);
if (suspiciousPatterns.some(pattern => pattern.test(queryStr))) {
logger.warn(`Suspicious input detected in query params from ${req.ip}: ${req.path}`);
}
}
// Continuer sans bloquer (logging only pour ne pas casser l'app)
next();
} catch (error) {
logger.error('Error in input validation middleware:', error);
next();
}
};
module.exports = {
inputValidationMiddleware,
sanitizeString,
sanitizeObject,
validateFileName,
validatePath,
validateUserId,
validateEmail
};

View File

@@ -0,0 +1,194 @@
/**
* Middleware de rate limiting renforcé
* Protège contre les abus et les attaques par force brute
*/
const rateLimit = require('express-rate-limit');
const { logger } = require('../config/logs');
/**
* Rate limiter général pour toutes les requêtes
*/
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // Limite de 1000 requêtes par fenêtre
message: {
error: 'Trop de requêtes depuis cette adresse IP, veuillez réessayer plus tard.',
retryAfter: '15 minutes'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Skip pour localhost
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`Rate limit exceeded for ${req.ip} on ${req.path}`);
res.status(429).json({
error: 'Trop de requêtes',
message: 'Vous avez dépassé la limite de requêtes autorisées. Veuillez réessayer dans 15 minutes.',
retryAfter: 900
});
}
});
/**
* Rate limiter strict pour les endpoints sensibles (authentification)
*/
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Seulement 5 tentatives de connexion
skipSuccessfulRequests: true, // Ne pas compter les tentatives réussies
message: {
error: 'Trop de tentatives de connexion, votre compte est temporairement verrouillé.',
retryAfter: '15 minutes'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`Auth rate limit exceeded for ${req.ip} on ${req.path}`);
res.status(429).json({
error: 'Compte temporairement verrouillé',
message: 'Trop de tentatives de connexion. Veuillez réessayer dans 15 minutes.',
retryAfter: 900
});
}
});
/**
* Rate limiter pour les uploads
*/
const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 heure
max: 100, // 100 uploads par heure
message: {
error: 'Limite d\'upload atteinte',
retryAfter: '1 heure'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`Upload rate limit exceeded for ${req.ip}`);
res.status(429).json({
error: 'Limite d\'upload dépassée',
message: 'Vous avez atteint la limite d\'uploads autorisés. Veuillez réessayer dans 1 heure.',
retryAfter: 3600
});
}
});
/**
* Rate limiter pour les API
*/
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 60, // 60 requêtes par minute
message: {
error: 'Limite d\'API atteinte',
retryAfter: '1 minute'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`API rate limit exceeded for ${req.ip} on ${req.path}`);
res.status(429).json({
error: 'Limite d\'API dépassée',
message: 'Vous avez dépassé la limite de requêtes API autorisées. Veuillez réessayer dans 1 minute.',
retryAfter: 60
});
}
});
/**
* Rate limiter pour la création de dossiers/fichiers
*/
const createLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 50, // 50 créations par 10 minutes
message: {
error: 'Limite de création atteinte',
retryAfter: '10 minutes'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`Create rate limit exceeded for ${req.ip} on ${req.path}`);
res.status(429).json({
error: 'Limite de création dépassée',
message: 'Vous avez dépassé la limite de créations autorisées. Veuillez réessayer dans 10 minutes.',
retryAfter: 600
});
}
});
/**
* Rate limiter pour les suppressions
*/
const deleteLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 30, // 30 suppressions par 5 minutes
message: {
error: 'Limite de suppression atteinte',
retryAfter: '5 minutes'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`Delete rate limit exceeded for ${req.ip} on ${req.path}`);
res.status(429).json({
error: 'Limite de suppression dépassée',
message: 'Vous avez dépassé la limite de suppressions autorisées. Veuillez réessayer dans 5 minutes.',
retryAfter: 300
});
}
});
/**
* Rate limiter strict pour la recherche d'utilisateurs (prévient l'énumération)
*/
const userSearchLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 10, // 10 recherches par minute
message: {
error: 'Limite de recherche atteinte',
retryAfter: '1 minute'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
return req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
},
handler: (req, res) => {
logger.warn(`User search rate limit exceeded for ${req.ip}`);
res.status(429).json({
error: 'Limite de recherche dépassée',
message: 'Vous avez dépassé la limite de recherches autorisées. Veuillez réessayer dans 1 minute.',
retryAfter: 60
});
}
});
module.exports = {
generalLimiter,
authLimiter,
uploadLimiter,
apiLimiter,
createLimiter,
deleteLimiter,
userSearchLimiter
};

View File

@@ -0,0 +1,76 @@
/**
* Middleware de sécurité pour ajouter les headers HTTP sécurisés
* Conforme aux bonnes pratiques OWASP
*/
const securityHeadersMiddleware = (req, res, next) => {
// Désactive l'envoi de l'en-tête X-Powered-By pour ne pas révéler la stack technique
res.removeHeader('X-Powered-By');
// Content Security Policy (CSP) - Protège contre les attaques XSS
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://code.jquery.com https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://cdn.jsdelivr.net https://cdn.tailwindcss.com",
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com",
"img-src 'self' data: https: blob:",
"font-src 'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com",
"connect-src 'self' ws: wss: https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
].join('; ')
);
// X-Content-Type-Options - Empêche le navigateur de deviner le type MIME
res.setHeader('X-Content-Type-Options', 'nosniff');
// X-Frame-Options - Protège contre le clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// X-XSS-Protection - Active la protection XSS du navigateur
res.setHeader('X-XSS-Protection', '1; mode=block');
// Strict-Transport-Security (HSTS) - Force HTTPS
if (req.secure || process.env.NODE_ENV === 'production') {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
// Referrer-Policy - Contrôle les informations de référence envoyées
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions-Policy - Contrôle les fonctionnalités du navigateur
res.setHeader(
'Permissions-Policy',
[
'camera=()',
'microphone=()',
'geolocation=()',
'payment=()',
'usb=()',
'magnetometer=()',
'accelerometer=()',
'gyroscope=()'
].join(', ')
);
// X-Permitted-Cross-Domain-Policies - Restreint les politiques cross-domain
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
// Cross-Origin-Embedder-Policy - Désactivé pour permettre les ressources externes (avatars, images CDN)
// res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// Cross-Origin-Opener-Policy - Isole le contexte de navigation
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
// Cross-Origin-Resource-Policy - Contrôle le partage de ressources
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
next();
};
module.exports = securityHeadersMiddleware;