feat(security): implement security fixes - Add AES-256-GCM encryption for Facebook/Instagram tokens - Add helmet for HTTP security headers - Add rate limiting (5 login attempts, 100 req/15min) - Add file upload validation (50MB max, MIME type whitelist) - Secure SQL console endpoint (disabled in production) - Enforce SESSION_SECRET in production

This commit is contained in:
Michael committed 2026-01-07 14:57:36 +01:00
1 parent c3660f811b
commit 633875e8ec
7 files changed
+782 -315

No files matched your search

+4
View File
@@ -16,6 +16,10 @@ APP_URL=http://localhost:5555
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
SESSION_SECRET=your-secret-key-change-me-to-random-string SESSION_SECRET=your-secret-key-change-me-to-random-string
# Clé de chiffrement pour les tokens Facebook/Instagram (OBLIGATOIRE en production)
# Générer avec: openssl rand -hex 32
ENCRYPTION_KEY=your-32-byte-hex-key-here
# Clé API OpenRouter pour la génération de texte IA # Clé API OpenRouter pour la génération de texte IA
# Obtenez votre clé sur https://openrouter.ai/ # Obtenez votre clé sur https://openrouter.ai/
OPENROUTER_API_KEY=your-openrouter-api-key-here OPENROUTER_API_KEY=your-openrouter-api-key-here
+569 -298
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -64,8 +64,10 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"emoji-regex": "^10.6.0", "emoji-regex": "^10.6.0",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"helmet": "^8.1.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
+57 -10
View File
@@ -2,6 +2,9 @@ import express, { type Request, Response, NextFunction } from "express";
import session from "express-session"; import session from "express-session";
import connectPgSimple from "connect-pg-simple"; import connectPgSimple from "connect-pg-simple";
import pg from "pg"; import pg from "pg";
import crypto from "crypto";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import passport from "./auth"; import passport from "./auth";
import { registerRoutes } from "./routes"; import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite"; import { setupVite, serveStatic, log } from "./vite";
@@ -11,6 +14,42 @@ import { ensureAdminUserExists } from "./init-admin";
const app = express(); const app = express();
const PgSession = connectPgSimple(session); const PgSession = connectPgSimple(session);
// Headers de sécurité HTTP avec helmet
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "wss:", "ws:"],
}
},
crossOriginEmbedderPolicy: false,
}));
// Rate limiting global - 100 requêtes par 15 minutes par IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Trop de requêtes, réessayez plus tard' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting strict pour l'authentification - 5 tentatives par 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Trop de tentatives de connexion, réessayez dans 15 minutes' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
declare module 'http' { declare module 'http' {
interface IncomingMessage { interface IncomingMessage {
rawBody: unknown rawBody: unknown
@@ -23,28 +62,36 @@ app.use(express.json({
})); }));
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
// Configuration des sessions // Validation renforcée du SESSION_SECRET
if (!process.env.SESSION_SECRET) { if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé par défaut (NON SÉCURISÉ en production)'); console.error('❌ SESSION_SECRET non défini en production. Arrêt du serveur.');
process.exit(1);
} }
if (!process.env.SESSION_SECRET) {
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé aléatoire pour le développement.');
}
// Générer un secret aléatoire pour le dev si non défini
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
// Déterminer si on utilise HTTPS basé sur APP_URL // Déterminer si on utilise HTTPS basé sur APP_URL
const isHttps = process.env.APP_URL?.startsWith('https://') || false; const isHttps = process.env.APP_URL?.startsWith('https://') || false;
// Configuration du store de session pour production // Configuration du store de session pour production
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
? new PgSession({ ? new PgSession({
pool: new pg.Pool({ pool: new pg.Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
}), }),
tableName: 'session', tableName: 'session',
createTableIfMissing: true, createTableIfMissing: true,
}) })
: undefined; // MemoryStore par défaut en dev : undefined; // MemoryStore par défaut en dev
app.use(session({ app.use(session({
store: sessionStore, store: sessionStore,
secret: process.env.SESSION_SECRET || 'your-secret-key-change-me', secret: sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
+32 -4
View File
@@ -11,7 +11,27 @@ import { cloudinaryService } from "./services/cloudinary";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema"; import type { User, InsertUser, ScheduledPost } from "@shared/schema";
const upload = multer({ storage: multer.memoryStorage() }); // Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/quicktime', 'video/webm'
];
// Configuration multer avec validation de taille et type
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max
files: 10 // 10 fichiers max
},
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
}
}
});
// Middleware pour vérifier l'authentification // Middleware pour vérifier l'authentification
function requireAuth(req: Request, res: Response, next: NextFunction) { function requireAuth(req: Request, res: Response, next: NextFunction) {
@@ -306,8 +326,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Route SQL (réservée aux admins) // Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
app.post("/api/sql/execute", requireAdmin, async (req, res) => { app.post("/api/sql/execute", requireAdmin, async (req, res) => {
// Vérification de sécurité: désactivé en production sauf si ALLOW_SQL_CONSOLE=true
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_SQL_CONSOLE !== 'true') {
return res.status(403).json({
success: false,
error: "Console SQL désactivée en production pour des raisons de sécurité"
});
}
try { try {
// Validation Zod // Validation Zod
const sqlQuerySchema = z.object({ const sqlQuerySchema = z.object({
@@ -642,8 +670,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
const badgeSvg = ` const badgeSvg = `
<svg width="${badgeWidth}" height="${badgeHeight}"> <svg width="${badgeWidth}" height="${badgeHeight}">
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}" <rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
rx="${badgeHeight/2}" ry="${badgeHeight/2}" fill="${color}"/> rx="${badgeHeight / 2}" ry="${badgeHeight / 2}" fill="${color}"/>
<text x="${badgeWidth/2}" y="${badgeHeight/2}" <text x="${badgeWidth / 2}" y="${badgeHeight / 2}"
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
fill="white" text-anchor="middle" dominant-baseline="middle"> fill="white" text-anchor="middle" dominant-baseline="middle">
${badgeText} ${badgeText}
+26 -3
View File
@@ -31,6 +31,7 @@ import {
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
export interface IStorage { export interface IStorage {
// Users // Users
@@ -131,21 +132,43 @@ export class DatabaseStorage implements IStorage {
// Social Pages // Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> { async getSocialPages(userId: string): Promise<SocialPage[]> {
return await db.select().from(socialPages).where(eq(socialPages.userId, userId)); const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
// Déchiffrer les tokens pour chaque page
return pages.map(page => ({
...page,
accessToken: decrypt(page.accessToken)
}));
} }
async getSocialPage(id: string): Promise<SocialPage | undefined> { async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
if (page) {
// Déchiffrer le token
page.accessToken = decrypt(page.accessToken);
}
return page || undefined; return page || undefined;
} }
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> { async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
const [newPage] = await db.insert(socialPages).values(page).returning(); // Chiffrer le token avant stockage
const encryptedPage = {
...page,
accessToken: encrypt(page.accessToken)
};
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
// Retourner avec le token déchiffré
newPage.accessToken = decrypt(newPage.accessToken);
return newPage; return newPage;
} }
async updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage> { async updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage> {
const [updated] = await db.update(socialPages).set(page).where(eq(socialPages.id, id)).returning(); // Chiffrer le token si présent dans la mise à jour
const updateData = page.accessToken
? { ...page, accessToken: encrypt(page.accessToken) }
: page;
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
// Retourner avec le token déchiffré
updated.accessToken = decrypt(updated.accessToken);
return updated; return updated;
} }
+92
View File
@@ -0,0 +1,92 @@
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
/**
* Récupère la clé de chiffrement depuis les variables d'environnement.
* Dérive une clé de 32 bytes pour AES-256.
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
// En développement, utiliser une clé par défaut (non sécurisé pour la production)
if (process.env.NODE_ENV !== 'production') {
console.warn('⚠️ ENCRYPTION_KEY non défini. Utilisation d\'une clé par défaut (développement uniquement)');
return crypto.scryptSync('dev-default-key-not-secure', 'salt', 32);
}
throw new Error('ENCRYPTION_KEY non défini dans les variables d\'environnement');
}
// Dériver une clé de 32 bytes depuis la clé fournie
return crypto.scryptSync(key, 'socialflow-salt', 32);
}
/**
* Chiffre une chaîne de texte avec AES-256-GCM.
* @param text - Le texte à chiffrer
* @returns Le texte chiffré au format: iv:authTag:encrypted (hex)
*/
export function encrypt(text: string): string {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
/**
* Déchiffre une chaîne chiffrée avec AES-256-GCM.
* @param encryptedText - Le texte chiffré au format iv:authTag:encrypted
* @returns Le texte déchiffré
*/
export function decrypt(encryptedText: string): string {
// Si le texte ne contient pas le format attendu, retourner tel quel
// (pour la rétrocompatibilité avec les tokens non chiffrés)
if (!encryptedText.includes(':')) {
return encryptedText;
}
const key = getEncryptionKey();
const parts = encryptedText.split(':');
if (parts.length !== 3) {
// Format invalide, retourner tel quel (rétrocompatibilité)
return encryptedText;
}
const [ivHex, authTagHex, encrypted] = parts;
try {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// Si le déchiffrement échoue, retourner tel quel (rétrocompatibilité)
console.warn('⚠️ Échec du déchiffrement, token probablement non chiffré');
return encryptedText;
}
}
/**
* Vérifie si un texte est déjà chiffré (format iv:authTag:encrypted).
* @param text - Le texte à vérifier
* @returns true si le texte semble être chiffré
*/
export function isEncrypted(text: string): boolean {
if (!text.includes(':')) return false;
const parts = text.split(':');
if (parts.length !== 3) return false;
// Vérifier que les parties ressemblent à du hex
return parts.every(part => /^[a-f0-9]+$/i.test(part));
}