# Audit de sécurité — office_translator

Date : 2026-08-26 · Branche : `production-deployment` · Périmètre : backend FastAPI, routes, middlewares, services, config, déploiement, dépôt git.

## Critiques

### C1. Secrets de production commis dans git
`.env.production` est **suivi par git** (confirmé via `git ls-files`) et contient des valeurs réelles :
- `JWT_SECRET_KEY` (ligne 65/81) — permet de forger n'importe quel JWT utilisateur/admin
- `ADMIN_TOKEN_SECRET`, `POSTGRES_PASSWORD`, `GRAFANA_PASSWORD`, `STRIPE_SECRET_KEY`
- `data/provider_settings.json` (suivi) contient un mot de passe SMTP en clair.
- Un fichier `.db` (1) est suivi : hashes de mots de passe et enregistrements utilisateurs potentiels.

**Action** : rotation de TOUS ces secrets, suppression des fichiers (`git rm --cached`), purge de l'historique (`git filter-repo`), et déplacement hors du dépôt des clés TLS privées présentes à la racine (`*.key`, non suivies mais à côté du code).

### C2. Traversée de chemin dans l'ingestion par URL
`routes/translate_routes.py:292-312` — le nom de fichier provient du `Content-Disposition` du serveur distant (contrôlé par l'attaquant puisque `file_url` est fourni par l'utilisateur) et est utilisé **non assaini** : `temp_path = config.UPLOAD_DIR / f"{unique_id}_{filename}"`. `filename="../..../evil.xlsx"` écrit hors de `UPLOAD_DIR`. La whitelist d'extensions ne bloque pas `..`. Contraste : l'upload direct est correctement assaini (`middleware/validation.py:239-259`).

### C3. SSRF par redirection
`routes/translate_routes.py:263-267` — le hostname est vérifié une fois par `_is_ssrf_risk()` (solide par ailleurs), mais la requête utilise `follow_redirects=True` sans revalidation : une URL publique peut rediriger 302 vers `169.254.169.254`, `localhost`, plages privées. Fenêtre TOCTOU DNS-rebinding en plus.

### C4. Job de cleanup : bug de clé + purge orpheline sans âge minimum
`middleware/cleanup.py:194` lit `metadata["file_path"]` alors que les fichiers sont suivis sous `"input_path"` (`translate_routes.py:837`) → `tracked_paths` toujours vide → **tout** fichier est classé orphelin, et la suppression orpheline n'a **aucune vérification d'âge** (`cleanup.py:236-241`) → boucle de 5 min supprime des fichiers de jobs en cours. DoS/raison de disponibilité. (`protect_file()` existe mais n'est jamais appelé.)

## Élevées

### H1. Zip-bomb non contré
Aucune limite de ratio de décompression ni de taille décompressée sur OOXML : `openpyxl.load_workbook`, `docx.Document`, `zipfile.read()` directs (`translators/word_translator.py:834+`, `pptx_translator.py:544+`). Un fichier de <50 MB très compressé → Go en RAM → OOM worker. Seule la taille compressée est vérifiée.

### H2. Téléchargement / statut : contrôle de propriété défaillant (IDOR)
- `routes/translate_routes.py:1879-1886` : le check de propriété sur `GET /download/{job_id}` n'est effectué que si un utilisateur est authentifié ; un appelant anonyme qui devine/fuit un job_id (12 hex ≈ 48 bits) télécharge le document traduit, et la suppression après 1er téléchargement permet un DoS contre le légitime propriétaire.
- `routes/translate_routes.py:1690-1779` : `GET /translations/{job_id}` n'a **aucun** check de propriété → énumération de statut, noms de fichiers, erreurs.

### H3. Traduction anonyme
`/translate` accepte les requêtes non authentifiées (`current_user` optionnel, ligne 547) → traitement gratuit au tarif payant, seule la limitation IP s'applique ; combiné au webhook_url, oracle d'egress gratuit. À confirmer si voulu (démo landing ?).

### H4. Rate limiting contournable via `X-Forwarded-For`
`middleware/rate_limiting.py:249-261` et `routes/admin_routes.py:176-189` font confiance au premier XFF sans proxy de confiance configuré → rotation d'XFF factice pour contourner rate limit et verrouillage brute-force admin (le verrouillage et les sessions admin sont aussi en mémoire par worker, `admin_routes.py:47-52`).

## Moyennes

- **M1. XXE/hardening XML** : `lxml.etree.fromstring` sur des parties ZIP non fiables sans `resolve_entities=False` ni defusedxml (`word_translator.py:731,839…`, `pptx_translator.py:553…`). Mitigé par libxml2 ≥2.9, mais à durcir explicitement.
- **M2. Rotation refresh token sans révocation** : `/refresh` (`routes/auth_routes.py:661-742`) ne révoque pas l'ancien refresh token (7 jours de vie, pas de détection de réutilisation). Reset de mot de passe sans révocation des sessions existantes.
- **M3. Fallback PyJWT absent** : jetons signés en base64 **non signé** silencieusement acceptés (`services/auth_service.py:158-169`) ; garde de production OK mais login/verify ne vérifient pas `JWT_AVAILABLE`.
- **M4. `/checkout/sync`** : ownership conditionnel (`services/payment_service.py:174-178`) si session sans `metadata.user_id`.
- **M5. Endpoint legacy batch** (`legacy_routes.py:248-320`) : pas de magic bytes, nombre de fichiers non borné, traitement synchrone → épuisement CPU/RAM. `/metrics` legacy non authentifié.
- **M6. Upload direct** : `await file.read()` charge tout le fichier (≤50 MB) en RAM avant vérification (`middleware/validation.py:116`) ; l'URL stream correctement.
- **M7. Dépendances vulnérables** (`requirements.txt`) : `python-multipart==0.0.9` (CVE-2024-24762, upload !), `fastapi==0.109.0` (ReDoS), `pydantic==2.5.3`, `stripe==7.0.0`. À mettre à jour en priorité.
- **M8. Temp files images** écrits dans le temp système, non couverts par le cleanup, fuités sur exception (`pptx_translator.py:1060-1066`, `word_translator.py:1273-1275`).

## Faibles

- Logout révoque un refresh token fourni sans check de propriété (`auth_routes.py:401-410`).
- Flux Google OAuth access_token sans validation d'audience (`auth_routes.py:604-609`).
- Admin mono-facteur mot de passe partagé ; envisager TOTP.
- Erreurs URL qui divulguent `str(e)` + URL interne au client (`translate_routes.py:356-371`).
- Caches mémoire non bornés (`_gc_key_cache`), jobs « processing » jamais purgés.
- Secret webhook Stripe lu à l'import au lieu du runtime (`payment_service.py:26`).

## Points forts constatés

- Vérification Stripe webhook correcte (signature + idempotence + body brut).
- JWT : algorithme épinglé HS256, jetons typés, révocation jti Redis, access 15 min.
- Clés API : stockées en SHA-256, haute entropie, expiration/révocation serveur ; glossaires/prompts correctement scopés par `user_id` (pas d'IDOR là).
- Anti-énumération (bcrypt factice constant-time, forgot-password toujours 200).
- Validation magic-bytes + allowlist extensions sur le flux v1 ; `_is_ssrf_risk` fail-closed ; streaming avec cap d'octets côté URL.
- Échec au démarrage en production si secrets manquants / CORS wildcard ; pas de SQL brut (ORM uniquement) ; bcrypt pour les mots de passe.

## Priorités de remédiation (ordre recommandé)

1. **Rotation immédiate** de tous les secrets de `.env.production` + purge historique git (C1).
2. Assainir le filename de l'ingestion URL (réutiliser `FileValidator._sanitize_filename`) (C2).
3. Revalider le SSRF à chaque hop de redirection (désactiver `follow_redirects`, suivre manuellement) (C3).
4. Corriger la clé `file_path`→`input_path` + âge minimum pour suppression orpheline (C4).
5. Exiger l'authentification + ownership sur `/download/{job_id}` et `/translations/{job_id}` (H2).
6. Limites de ratio zip (H1) ; XFF/proxy de confiance + verrouillage Redis (H4).
7. Mise à jour `python-multipart`, `fastapi`, `pydantic` (M7).

---

## Suivi des correctifs (2026-08-26)

**Corrigés au code :**
- ✅ C1 (partie git) : `.env.production`, `.env.ionos`, `data/provider_settings.json`, `translations.db` retirés du suivi git + `.gitignore`. ⚠️ rotation des secrets + purge de l'historique restent à faire manuellement.
- ✅ C2 : nom de fichier assaini dans l'ingestion URL (`_sanitize_url_filename`).
- ✅ C3 : redirections suivies manuellement avec revalidation SSRF à chaque étape (max 5).
- ✅ C4 : clés `input_path`/`file_path`/`output_path` reconnues + délai de grâce de 15 min avant suppression d'un fichier orphelin.
- ✅ H1 : contrôle anti-fichier-piège (`validate_zip_safety`, ratio max 100:1, 1 Go décompressé) sur les flux v1 et legacy.
- ✅ H2 : contrôle d'accès strict sur `/translations/{id}` et `/download/{id}` (propriétaire obligatoire ; jeton secret requis pour les jobs sans compte).
- ✅ H3 (partiel) : les jobs anonymes nécessitent désormais le jeton secret ; la création anonyme reste possible.
- ✅ M7 : `python-multipart` 0.0.9 → 0.0.20, `fastapi` 0.109.0 → 0.109.1.

**Restent à faire :** rotation des secrets + purge historique git (manuel), H4 (en-tête X-Forwarded-For), M2 (révocation refresh token), M4, M5, M6.
