# Audit Technique XAMLÉ — Guide Développeur **Date :** 15 Mai 2026 **Statut :** Référence active — à mettre à jour après chaque correction majeure --- ## Résumé exécutif | # | Problème | Sévérité | Statut | |---|---|---|---| | 1 | `.env` commité avec secrets réels (tokens Meta, OpenAI, DB, R2) | 🔴 CRITIQUE | À régler | | 2 | SQL Injection sur `/v1/analytics/query` (text-to-SQL) | 🔴 CRITIQUE | À régler | | 3 | JWT token exposé en query param dans les URLs SSE | 🟠 HAUT | Workaround actif | | 4 | OAuth WhatsApp sans validation CSRF (state parameter) | 🟠 HAUT | À régler | | 5 | File upload sans limite de taille dans `/v1/admin/training/upload` | 🟠 HAUT | À régler | | 6 | Pas de Dead Letter Queue — jobs BullMQ échoués silencieusement | 🟠 HAUT | À régler | | 7 | Validation Zod incomplète au démarrage Worker | 🟠 HAUT | Partiel | | 8 | `META_GRAPH_API_VERSION` : fallback hardcodé dans 7 fichiers | 🟡 MOYEN | Partiel | | 9 | Index DB manquants sur tables Analytics/Message/User | 🟡 MOYEN | À régler | | 10 | JWT en sessionStorage (XSS possible) | 🟡 MOYEN | À régler | | 11 | Catch silencieux dans `fetchMetaStatus` | 🟡 MOYEN | Partiellement corrigé | | 12 | `wabaId` sans contrainte unique (retiré le 15/05) | 🟡 MOYEN | Choix délibéré, surveiller | --- ## Architecture réelle du système ``` ┌──────────────────────────────────────────────────────────────────┐ │ Admin Frontend (Netlify + React/Vite) │ │ VITE_API_URL = https://safetrack-edtech.hf.space │ │ Auth : JWT en sessionStorage → header Authorization: Bearer ... │ │ SSE : ?token= en query param (limite EventSource) │ └───────────────────────────┬──────────────────────────────────────┘ │ REST (Bearer JWT) │ SSE (?token=) ▼ ┌──────────────────────────────────────────────────────────────────┐ │ HuggingFace Space "safetrack/edtech" [port 7860] │ │ apps/api — Fastify v4 + Prisma + Redis │ │ Routes : /v1/auth, /v1/organizations, /v1/analytics, /v1/admin │ │ ⚠️ Réseau HF bloqué vers graph.facebook.com │ └───────────────────────────┬──────────────────────────────────────┘ │ BullMQ (Upstash Redis partagé) │ HTTP POST + ADMIN_API_KEY ▼ ┌──────────────────────────────────────────────────────────────────┐ │ Railway "whatsapp-worker" │ │ PM2 : API Fastify [7860] + Worker BullMQ + Bridge [8082] │ │ ✅ Seul service à pouvoir appeler graph.facebook.com │ │ ✅ Seul service à déchiffrer + utiliser systemUserToken │ └──────────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ Neon PostgreSQL Cloudflare R2 (multi-tenant) (audio/images) ``` ### Règles fondamentales à ne pas violer 1. **Répondre 200 OK en < 100ms** au webhook Meta, puis `setImmediate()` pour le traitement 2. **Prisma AVANT sendWhatsApp** — ne jamais envoyer un message si la DB a échoué 3. **Verrou Redis (300s)** sur chaque job qui appelle un LLM ou envoie un message WhatsApp 4. **HuggingFace ne peut pas appeler Meta directement** — tout passe par BullMQ → Worker Railway 5. **ENCRYPTION_SECRET identique** sur HF et Railway — sinon les tokens ne peuvent pas être déchiffrés --- ## Problèmes détaillés ### 🔴 CRITIQUE — `.env` commité avec secrets réels **Fichier :** `/.env` (à la racine du repo) **Ce qui est exposé :** - `WHATSAPP_ACCESS_TOKEN` (token Meta long-lived `EAA...`) - `OPENAI_API_KEY` (clé OpenAI) - `GOOGLE_AI_API_KEY` (clé Gemini) - `DATABASE_URL` (URL Neon PostgreSQL avec mot de passe) - `R2_SECRET_ACCESS_KEY` (clé Cloudflare R2) - `ENCRYPTION_SECRET` (clé de chiffrement des tokens orgs en DB) **Impact :** Toute personne avec accès au repo (collaborateurs, forks, GitHub history) peut accéder à tous les services de production. **Procédure de correction :** ```bash # 1. Ajouter au .gitignore (si pas déjà fait) echo ".env" >> .gitignore # 2. Retirer du tracking git git rm --cached .env # 3. Supprimer de l'historique git (BFG) brew install bfg bfg --delete-files .env git reflog expire --expire=now --all && git gc --prune=now --aggressive git push origin main --force # 4. Révoquer TOUS les secrets immédiatement (même si l'historique est nettoyé) # - Meta : developers.facebook.com → App → WhatsApp → Révoquer token # - OpenAI : platform.openai.com → API Keys → Delete # - Google AI : console.cloud.google.com → APIs & Services → Credentials # - Neon : console.neon.tech → Reset password # - Cloudflare R2 : Cloudflare Dashboard → R2 → API tokens → Delete ``` --- ### 🔴 CRITIQUE — SQL Injection sur `/v1/analytics/query` **Fichier :** `apps/api/src/routes/analytics.ts` **Fonctionnement actuel :** Le endpoint reçoit une question en langage naturel → GPT-4o génère du SQL → le SQL est exécuté via `prisma.$executeRawUnsafe`. **Défenses actuelles (insuffisantes) :** ```typescript // 1. Regex UNION — bypassable par commentaires SQL if (/union/i.test(sql)) return 400; // ❌ /*uni*/on passe // 2. Whitelist de tables — extraction regex incomplète const tables = [...sql.matchAll(/(?:FROM|JOIN)\s+"?(\w+)"?/gi)]; // ❌ Rate les sous-requêtes : SELECT * FROM (SELECT * FROM Organization) AS t // 3. Injection organizationId — remplacement naïf const safeSql = sql.replace(/''/g, `'${orgId}'`); // ❌ Si le LLM laisse dans un contexte non prévu ``` **Scénario d'attaque :** Un admin formule une question qui conduit GPT à générer un SQL accédant aux données d'une autre organisation. **Correction recommandée :** ```typescript // Option A : Remplacer par un query builder paramétré // Le LLM retourne une structure JSON, pas du SQL brut const QuerySchema = z.object({ table: z.enum(['Message', 'Enrollment', 'User', 'Contact']), filters: z.record(z.union([z.string(), z.number()])), groupBy: z.string().optional(), orderBy: z.string().optional(), limit: z.number().max(1000).optional(), }); // Construire le SQL avec Prisma à partir de cette structure // Option B : Parser le SQL (AST) avant exécution import { parse } from 'node-sql-parser'; const ast = parse(sql); // Vérifier que toutes les tables dans l'AST sont dans la whitelist // Vérifier qu'il n'y a pas de sous-requêtes, CTEs, etc. ``` --- ### 🟠 HAUT — JWT en query param pour SSE **Fichiers :** - `apps/api/src/middleware/verifyJwt.ts` (backend) - `apps/admin/src/components/layouts/MainLayout.tsx` (frontend) - `apps/admin/src/pages/CrmConversationalDashboard.tsx` (frontend) **Contexte :** L'API `EventSource` du navigateur ne supporte pas les headers custom. Solution de contournement : passer le JWT en `?token=` dans l'URL. **Risques :** - Token visible dans les logs proxy/CDN/WAF - Token dans l'historique du navigateur - Token exposé en cas de partage d'écran ou de screenshot DevTools **Code actuel :** ```typescript // Backend (verifyJwt.ts) const queryToken = (request.query as Record)?.token; if (queryToken && !request.headers.authorization) { request.headers.authorization = `Bearer ${queryToken}`; } // Frontend (MainLayout.tsx) const es = new EventSource(`${apiBase}/v1/organizations/${orgId}/stream?token=${encodeURIComponent(token)}`); ``` **Correction à long terme :** Utiliser un token SSE éphémère (court TTL, scope limité) : ```typescript // 1. Frontend demande un token SSE court (15 min) const { sseToken } = await api.post('/v1/auth/sse-token', {}, longJwt); // 2. Backend génère un JWT avec scope='sse' + TTL 15 min app.post('/v1/auth/sse-token', async (req) => { const sseToken = fastify.jwt.sign( { userId: req.user.id, orgId: req.user.organizationId, scope: 'sse' }, { expiresIn: '15m' } ); return { sseToken }; }); // 3. Backend vérifie le scope sur le stream endpoint if (payload.scope !== 'sse') return reply.code(403); ``` --- ### 🟠 HAUT — OAuth WhatsApp sans CSRF protection **Fichier :** `apps/api/src/routes/organizations.ts` (ligne ~163) **Problème :** L'échange du code OAuth Meta ne vérifie pas un paramètre `state`, exposant au Cross-Site Request Forgery : ```typescript // ❌ Pas de vérification state const exchangeRes = await fetch( `https://graph.facebook.com/oauth/access_token?client_id=${appId}&code=${realToken}&redirect_uri=` ); ``` **Correction :** ```typescript // Avant de lancer le flow OAuth const state = crypto.randomUUID(); await redis.set(`oauth:state:${state}`, organizationId, 'EX', 600); // Dans le callback const savedOrgId = await redis.get(`oauth:state:${req.body.state}`); if (!savedOrgId || savedOrgId !== id) { return reply.code(400).send({ error: 'Invalid OAuth state — possible CSRF attack' }); } await redis.del(`oauth:state:${req.body.state}`); ``` --- ### 🟠 HAUT — File upload sans limite de taille **Fichier :** `apps/api/src/routes/admin.ts` (endpoint `POST /training/upload`) **Problème :** ```typescript for await (const part of parts) { if (part.type === 'file') { fileBuffer = await part.toBuffer(); // ❌ Aucune limite de taille } } ``` Un fichier de 1 GB serait chargé entièrement en mémoire → crash du service. **Correction :** ```typescript const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB // Configurer aussi dans Fastify : fastify.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } }); ``` --- ### 🟠 HAUT — Pas de Dead Letter Queue (DLQ) sur BullMQ **Fichier :** `apps/whatsapp-worker/src/index.ts` **Problème :** Après 3 tentatives échouées, un job disparaît sans alerte. Les messages WhatsApp ratés ne sont pas notifiés. **Correction :** ```typescript worker.on('failed', async (job, err) => { logger.error({ jobId: job?.id, name: job?.name, err }, '[WORKER] Job failed after all retries'); // Stocker en DLQ Redis pour inspection manuelle await redis.lpush(`dlq:${job?.data?.organizationId}`, JSON.stringify({ jobId: job?.id, name: job?.name, error: err.message, data: job?.data, failedAt: new Date().toISOString() })); }); worker.on('error', (err) => { logger.error({ err }, '[WORKER] Worker-level error'); }); ``` --- ### 🟡 MOYEN — Validation d'environnement incomplète au démarrage **Fichiers :** `apps/whatsapp-worker/src/config.ts`, `apps/api/src/index.ts` **Variables critiques non validées au démarrage :** | Variable | Service | Problème | |---|---|---| | `META_GRAPH_API_VERSION` | API + Worker | Hardcodé `'v22.0'` comme fallback — n'échoue pas si absent | | `DISABLE_WORKER_CONSUMER` | Worker | Simple check booléen sans validation | | `WHATSAPP_APP_SECRET` | API | Non validé — utilisé seulement dans l'OAuth flow | | `META_APP_ID` | API | Non validé — requis pour OAuth | | `WORKER_CONCURRENCY` | Worker | Pas de validation min/max | **Correction — ajouter dans le schéma Zod existant :** ```typescript // apps/whatsapp-worker/src/config.ts const envSchema = z.object({ // ... existing vars ... META_GRAPH_API_VERSION: z.string().regex(/^v\d+\.0$/).default('v22.0'), DISABLE_WORKER_CONSUMER: z.enum(['true', 'false']).default('false'), WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(20).default(5), }); ``` --- ### 🟡 MOYEN — Index DB manquants **Fichier :** `packages/database/prisma/schema.prisma` **Index à ajouter :** ```prisma model User { // Ajouter : @@index([organizationId, role]) // pour listing admins @@index([organizationId, deletedAt]) // pour soft-deletes } model Message { // Ajouter : @@index([organizationId, direction, status, createdAt]) // pour analytics } model Enrollment { // Ajouter : @@index([organizationId, status]) // pour users actifs } ``` **Procédure d'application :** ```bash # Éditer le schema, puis : pnpm --filter @repo/database migrate:dev --name add_missing_indexes # ⚠️ La shadow DB Neon bloque sur pgvector — appliquer en SQL direct si nécessaire ``` --- ### 🟡 MOYEN — JWT en sessionStorage (vulnérabilité XSS) **Fichier :** `apps/admin/src/lib/auth.tsx` **Problème :** Un script XSS peut voler le token depuis `sessionStorage.getItem('session')`. **Correction à long terme :** Passer aux cookies `httpOnly` : ```typescript // Backend : Set-Cookie à la connexion reply.setCookie('jwt', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 86400, path: '/' }); // Frontend : le cookie est envoyé automatiquement, pas besoin de le stocker // Ajouter `credentials: 'include'` dans les fetch ``` --- ### 🟡 MOYEN — `wabaId` sans contrainte unique **Contexte :** La contrainte `@unique` a été retirée le 15/05/2026 pour permettre à plusieurs orgs de partager le même compte WABA (ex: test4 + testcrm). **Risque résiduel :** Si deux orgs ont le même `wabaId` par erreur, le routing des messages WhatsApp est déterministe (lié au `phoneNumberId`, pas au `wabaId`), donc pas de données mixées — mais c'est à surveiller. **Vérification périodique :** ```typescript // Ajouter un check au démarrage de l'API const dupes = await prisma.$queryRaw` SELECT "wabaId", COUNT(*) c FROM "Organization" WHERE "wabaId" IS NOT NULL GROUP BY "wabaId" HAVING COUNT(*) > 1 `; if (dupes.length > 0) logger.warn({ dupes }, '[STARTUP] Duplicate wabaIds detected'); ``` --- ## Variables d'environnement — référence complète ### Variables par service | Variable | API (HF) | Worker (Railway) | Validée Zod | Notes | |---|---|---|---|---| | `DATABASE_URL` | ✅ | ✅ | ✅ | Neon PostgreSQL — identique sur les deux | | `REDIS_URL` | ✅ | ✅ | ✅ | Upstash Redis — identique sur les deux | | `JWT_SECRET` | ✅ | — | ✅ check manuel | Min 64 chars recommandé | | `ADMIN_API_KEY` | ✅ | ✅ | ✅ min 32 chars | Auth interne Worker→API | | `ENCRYPTION_SECRET` | ✅ | ✅ | ✅ min 32 chars | **Doit être identique** | | `WHATSAPP_ACCESS_TOKEN` | ✅ | ✅ | ❌ | Fallback global si token org absent | | `WHATSAPP_PHONE_NUMBER_ID` | ✅ | ✅ | ❌ | Fallback global | | `WHATSAPP_VERIFY_TOKEN` | ✅ | — | ❌ | Vérification webhook Meta | | `WHATSAPP_APP_SECRET` | ✅ | — | ❌ | Requis pour OAuth Embedded Signup | | `META_APP_ID` | ✅ | — | ❌ | Requis pour OAuth Embedded Signup | | `META_GRAPH_API_VERSION` | ✅ | ✅ | ❌ | Défaut `v22.0` — changer ici d'abord | | `OPENAI_API_KEY` | ✅ | ✅ | ❌ | STT + GPT-4o | | `GOOGLE_AI_API_KEY` | ✅ | ✅ | ❌ | Gemini Flash/Pro | | `SENTRY_DSN` | ✅ | ✅ | ❌ | Error tracking | | `CORS_ORIGINS` | ✅ | — | ❌ | ex: `https://admin.xamle.studio` | | `R2_ACCOUNT_ID` | ✅ | — | ❌ | Cloudflare R2 | | `R2_ACCESS_KEY_ID` | ✅ | — | ❌ | Cloudflare R2 | | `R2_SECRET_ACCESS_KEY` | ✅ | — | ❌ | Cloudflare R2 | | `R2_BUCKET` | ✅ | — | ❌ | Cloudflare R2 | | `VAPID_PUBLIC_KEY` | ✅ | — | ❌ | Web Push | | `VAPID_PRIVATE_KEY` | ✅ | — | ❌ | Web Push | | `DISABLE_WORKER_CONSUMER` | `true` | `false` ou absent | ❌ | Empêche HF de consommer la queue | | `DISABLE_WHATSAPP_SEND` | optionnel | optionnel | ❌ | Feature flag pour tests | | `RAILWAY_INTERNAL_URL` | ✅ | — | ❌ | URL bridge Railway (`:8082`) | | `WORKER_CONCURRENCY` | — | `5` | ❌ | Jobs parallèles BullMQ | | `PORT` | `7860` | `7860` | ✅ | | | `NODE_ENV` | `production` | `production` | ✅ | | ### Règle critique `ENCRYPTION_SECRET` > La valeur en DB est chiffrée avec la clé active au moment du premier `encrypt_existing_secrets.ts`. Si cette clé change, **tous les tokens org retournent le ciphertext brut** → Authentication Error Meta sur chaque message entrant. Ne jamais changer sans exécuter `apps/api/scratch/rotate_encryption_key.ts`. --- ## Organisations en base (état 15 mai 2026) | ID | Nom | WABA ID | Token | Numéro WhatsApp | |---|---|---|---|---| | `default-org-id` | XAMLÉ Global | `938685848818318` | ✅ `EAAURe...` | `969048009628694` | | `ba012b65-...` | testcrm | `1503271284790621` | ✅ | `+221788227676` | | `12247ec1-...` | test4 | `1503271284790621` | ✅ copié testcrm | aucun (à configurer) | | `136f72d9-...` | test | null | — | — | | `xamle-admin-org` | XAMLÉ Admin | null | — | — | **Note :** test4 et testcrm partagent le même WABA ID — c'est délibéré. La contrainte unique a été retirée pour ce cas d'usage. --- ## Scripts de diagnostic et maintenance Tous les scripts sont dans `apps/api/scratch/`. Les exécuter depuis `apps/api/` avec les env vars du `.env`. ```bash # Lister toutes les orgs cd apps/api && npx tsx scratch/list_orgs.ts # Configurer une nouvelle org avec un token existant cd apps/api && npx tsx scratch/connect_.ts ``` ### Commandes de diagnostic rapide ```bash # Vérifier les doublons wabaId DATABASE_URL=... npx tsx -e " import { PrismaClient } from '@prisma/client'; const p = new PrismaClient(); const r = await p.\$queryRaw\`SELECT \"wabaId\", COUNT(*) FROM \"Organization\" WHERE \"wabaId\" IS NOT NULL GROUP BY \"wabaId\" HAVING COUNT(*) > 1\`; console.log(r); " # Vérifier les jobs BullMQ en attente / échoués redis-cli -u $REDIS_URL LLEN "bull:whatsapp-queue:wait" redis-cli -u $REDIS_URL LLEN "bull:whatsapp-queue:failed" # Vider le cache Meta Status redis-cli -u $REDIS_URL --scan --pattern "meta:status:*" | xargs redis-cli -u $REDIS_URL del # Vérifier la version Meta Graph API au démarrage (lire les logs) # Chercher : ✅ [META] Graph API version: v22.0 ``` --- ## Dépendances critiques entre services ``` Webhook Meta → API (HF) [< 100ms] → BullMQ (Redis) → Worker (Railway) ↑ Redis shared — REDIS_URL doit être identique ! Worker → graph.facebook.com (Meta) ← seul chemin autorisé Worker → api-inference.huggingface.co (modèles HF optionnel) API → Neon DB (queries + tenant extension) API → Redis (cache + sessions) API → OpenAI / Gemini (AI features) API → Cloudflare R2 (audio/images) Frontend → API (toutes les routes REST) Frontend → API/stream (SSE notification badge) ``` **Points de défaillance uniques :** | Service down | Impact | Récupération | |---|---|---| | Redis | Messages non envoyés, pas de sessions | Redémarrer + vider les queues orphelines | | Neon DB | Toute l'app inopérante | Attendre la récupération Neon (HA automatique) | | Meta API | Messages non envoyés | BullMQ retente 3× avec backoff | | Worker Railway | Messages s'accumulent en queue | Redéployer le service Railway | | HF Space | Frontend et API inaccessibles | Réveiller le Space (peut hiberner) | --- ## Problèmes connus non critiques ### `resolveOrgId` — Full scan sur slug ```typescript // organisations.ts ligne ~29 // OR clause sur des champs non-indexés ensembles const org = await prisma.organization.findFirst({ where: { OR: [{ slug: idOrSlug }, { id: idOrSlug }] }, }); // Correction : vérifier si UUID, puis lookup par slug, puis par id ``` ### Token fallback implicite dans whatsapp-setup La chaîne `body.token → env.WHATSAPP_ACCESS_TOKEN → org.systemUserToken` peut utiliser le mauvais token en multi-tenant. Ajouter un log sur la source choisie. ### SSE ne se reconnecte pas en cas d'erreur réseau ```typescript // Actuel : es.onerror = () => es.close(); // ❌ ferme sans se reconnecter // Mieux : let reconnectDelay = 1000; const connectSSE = () => { const es = new EventSource(url); es.onerror = () => { es.close(); setTimeout(connectSSE, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 30000); }; es.onopen = () => { reconnectDelay = 1000; }; }; connectSSE(); ``` --- ## Checklist démarrage rapide (nouveau développeur) ### Setup local ```bash git clone https://github.com/blackpanthere/Edtech.git cd Edtech pnpm install cp .env.example .env # Remplir avec des secrets DEV (pas production!) pnpm --filter @repo/database generate pnpm --filter @repo/database migrate:deploy ``` ### Démarrer les services ```bash # Terminal 1 — API pnpm --filter api dev # Vérifier dans les logs : ✅ [META] Graph API version: v22.0 # Terminal 2 — Worker pnpm --filter whatsapp-worker dev # Vérifier dans les logs : 🚀 WhatsApp Worker + Bridge listening on port 8082 # Terminal 3 — Admin frontend pnpm --filter admin dev # Ouvrir : http://localhost:5173 ``` ### Santé checks ```bash curl http://localhost:7860/health # API curl http://localhost:8082/health # Worker Bridge ``` ### Avant tout commit ```bash pnpm run typecheck pnpm --filter api test ``` ### Références obligatoires - `docs/post-mortems.md` — bugs passés et règles à ne pas violer - `tasks/lessons.md` — leçons tirées de l'expérience - `docs/organisation-onboarding/configurer-token-nouvelle-org.md` — onboarding Meta --- ## Priorités de correction (par effort) | Effort | Problème | Impact si corrigé | |---|---|---| | 1h | Catch silencieux → `logger.warn/error` | Bugs visibles en production | | 1h | Limite taille file upload | Prévenir DoS | | 2h | Secrets hors `.env` (rotation + .gitignore) | Sécurité critique | | 2h | DLQ BullMQ + alerting jobs échoués | Visibilité opérationnelle | | 3h | OAuth state parameter | Protection CSRF | | 3h | SSE reconnexion automatique | UX en cas de coupure réseau | | 4h | SQL Injection analytics → query builder | Sécurité critique | | 4h | Token SSE éphémère | Réduire exposition JWT | | 5h | httpOnly cookies | Supprimer risque XSS sur auth | | 5h | Rate limit BullMQ par tenant | Équité multi-tenant |