mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
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:
1 parent
c3660f811b
commit
633875e8ec
7 files changed
+782
-315
No files matched your search
@@ -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
|
||||||
|
|||||||
Generated
+569
-298
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user