feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
Translation quality & format preservation: - Word: merge adjacent same-format runs into one unit (sentence-level coherence like inline-tag handling); translate comments/balloons; dedupe textbox collection (was translated twice); RTL no longer overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs) - PPTX: chart translations now actually reach the output file (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP); CJK typeface hints (a:ea) - Excel: sheet renames no longer break references — rewrite cell formulas (3D/quoted), defined names, data validations, cond. formats - PDF: bold/italic honored (hebo/heit/hebi); table cells never merge; unchanged blocks left untouched (typography preserved, fixes duplicate hyperlinks); attempted/changed stats + route gate now cover PDF; CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings) Features: - formality param (formal/informal) + automatic regional-variant prompts - output_mode=bilingual docx (source above translation) - per-user translation memory on Redis (falls back to LRU), context-hashed - QA report + 0-100 confidence score in job status; L0 on by default - OpenAI-compatible providers: whole chunk in ONE numbered-JSON request (~15x fewer calls) with per-item fallback; base prompt always present (custom prompt no longer replaces translation instructions) Infra & marketing alignment: - plan-based engine gating + vision gating (closes paid-engine leak); /providers/available filtered per plan; 107 languages exposed - zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash) - admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin (TestClient breakage); Prometheus test fixture fixed - marketing docs aligned with code (PDF+OCR, retention, engines, pricing) - security: .env.ionos/.env.production/provider_settings.json removed Tests: 1173 passed / 0 failed (6 network tests deselected: free Google endpoint temporarily blocked from this machine)
This commit is contained in:
93
_bmad-output/audit-securite-2026-08-26.md
Normal file
93
_bmad-output/audit-securite-2026-08-26.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: 'Quick wins pipeline LLM : prompt, connexions, cache, event loop'
|
||||
type: 'bugfix'
|
||||
created: '2026-08-26'
|
||||
status: 'draft'
|
||||
context: []
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** Le pipeline de traduction LLM souffre de 4 défauts vérifiés : (1) dans les 3 providers LLM, un prompt personnalisé/glossaire **remplace** le prompt par défaut — la paire de langues et les règles métier disparaissent de la requête ; (2) chaque segment traduit ouvre une nouvelle connexion TCP+TLS (`requests.post` nu) ; (3) le cache de traduction Redis/LRU (`services/translation_cache.py`) est écrit et testé mais **jamais branché** — re-traduire un fichier re-facture 100 % des appels ; (4) 7 appels bloquants (SHA-256 de fichiers, DB, disque, sonde réseau Google) s'exécutent directement dans l'event loop et peuvent geler tout le serveur.
|
||||
|
||||
**Approach:** 4 correctifs chirurgicaux dans la couche providers (openai/minimax/deepseek) et le runner de jobs de `translate_routes.py`, sans changement d'interface publique ni des translators.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:** L'interface `TranslationProvider` / `TranslationRequest` / `TranslationResponse` reste inchangée ; la paire de langues doit **toujours** figurer dans le system prompt ; le cache reste piloté par env (`REDIS_CACHE_TTL`, `LRU_CACHE_MAXSIZE`, `REDIS_URL`) ; tout échec du cache est silencieux (fallback LRU, puis poursuite sans cache) ; `from_cache=True` sur les réponses servies du cache.
|
||||
|
||||
**Ask First:** modifier `build_full_prompt` (services/glossary_service.py) ; modifier les schémas providers ; introduire un nouveau backend de cache.
|
||||
|
||||
**Never:** Pas de batch multi-segments LLM (chantier suivant) ; pas de refonte de la double couche providers legacy/nouvelle ; pas de modification des translators/ ; pas de nouvel endpoint ; pas de persistance des jobs.
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Prompt custom actif | glossaire et/ou prompt utilisateur | System prompt = prompt par défaut formaté (avec paire de langues) **+** contexte custom en complément | N/A |
|
||||
| Segment déjà en cache | même texte + langues + provider + hash prompt | Réponse `from_cache=True`, 0 requête HTTP | N/A |
|
||||
| Segment répété dans un même document | 2e occurrence après succès de la 1ʳᵉ | Servie par le cache (le batch est séquentiel) | N/A |
|
||||
| Redis indisponible | `get_cache()` init ou `set()` en échec | Fallback LRU RAM puis non-bloquant ; traduction réussie quand même | Log warning, jamais d'exception |
|
||||
| Provider en erreur | échec API après retries | Rien n'est écrit dans le cache | Erreur existante propagée |
|
||||
| Fichier 50 Mo + glossaire configuré | job lancé pendant que d'autres requêtes arrivent | SHA-256, zip-safety, DB, settings et sonde Google hors event loop (`asyncio.to_thread`) | Erreurs existantes propagées |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `services/providers/openai_provider.py` -- `_build_system_prompt` :120-128 (bug du remplacement), `requests.post` :263, `translate_text` :426-455 (point d'entrée cache)
|
||||
- `services/providers/minimax_provider.py` -- prompt inline :181-183, `requests.post` :117, `translate_text` :168+
|
||||
- `services/providers/deepseek_provider.py` -- prompt inline :164-166, `requests.post` :111, `translate_text` :151+
|
||||
- `services/translation_cache.py` -- API prête et testée : `make_cache_key` :45, `hash_prompt` :77, `get_cache()` :402 (Redis auto + fallback LRU) — aucun appelant hors tests
|
||||
- `routes/translate_routes.py` -- appels bloquants dans coroutines : `calculate_sha256` :858/:871, `validate_zip_safety` :884, `_load_admin_settings` :1174, `get_glossary_terms` :1191, `get_prompt_content` :1206, `_google_cloud_key_valid` :1242 (fait une traduction HTTP de test synchrone)
|
||||
- `tests/test_providers/` -- 8 fichiers existants, dont `test_minimax_provider.py` (récent) : patterns de mock à réutiliser
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution:**
|
||||
- [ ] `services/providers/openai_provider.py` -- (1) `_build_system_prompt` : concaténer le prompt par défaut formaté + le custom au lieu de `return custom_prompt` ; (2) `requests.Session` d'instance réutilisée dans `_make_api_request` ; (3) `translate_text` : lookup `get_cache()` + `make_cache_key(..., custom_prompt_hash=hash_prompt(custom_prompt))` avant l'appel, `cache.set` après succès -- corrige le bug qualité, la latence et le coût
|
||||
- [ ] `services/providers/minimax_provider.py` -- mêmes 3 changements (prompt construit inline :181-183)
|
||||
- [ ] `services/providers/deepseek_provider.py` -- mêmes 3 changements (prompt construit inline :164-166)
|
||||
- [ ] `routes/translate_routes.py` -- envelopper les 7 appels bloquants listés dans la Code Map dans `asyncio.to_thread` -- l'event loop reste réactif pendant les jobs
|
||||
- [ ] `tests/test_providers/` -- nouveaux tests couvrant la matrice I/O : le system prompt contient la paire de langues **et** le custom (×3 providers) ; cache hit → 0 appel HTTP et `from_cache=True` ; erreur provider → rien mis en cache ; Redis down → traduction quand même
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given un custom_prompt sans mention de langue, when traduction via openai/minimax/deepseek, then le payload contient « from {source} to {target} » et le contenu custom.
|
||||
- Given un segment déjà en cache (même clé), when re-traduit, then aucune requête HTTP n'est émise vers l'API LLM.
|
||||
- Given Redis down, when traduction, then réponse normale via LRU ou sans cache, sans exception remontée au job.
|
||||
- Given la suite de tests existante, when `pytest -x`, then 0 régression.
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Design Notes
|
||||
|
||||
Concaténation du prompt (les 3 providers) :
|
||||
|
||||
```python
|
||||
system_prompt = DEFAULT_TRANSLATION_PROMPT.format(
|
||||
source_lang=source_lang_name, target_lang=target_lang_name
|
||||
)
|
||||
if custom_prompt:
|
||||
system_prompt += (
|
||||
"\n\nAdditional context and instructions from the user "
|
||||
f"(comply without overriding the language pair above):\n{custom_prompt}"
|
||||
)
|
||||
```
|
||||
|
||||
La `requests.Session` est créée dans `__init__` du provider (le pooling urllib3 est thread-safe ; les translators appellent `translate_text` depuis 6 threads). Clé de cache : réutiliser `make_cache_key(text, target_language, source_language, self._provider_name, custom_prompt_hash=hash_prompt(custom_prompt))` — le hash du prompt isole déjà les glossaires différents ; `user_id` absent des metadata aujourd'hui → valeur par défaut « anon » (partage inter-utilisateurs acceptable : mémoire de traduction standard, TTL 24 h).
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `pytest tests/test_providers/ -x` -- expected: succès, nouveaux tests inclus
|
||||
- `pytest -x` -- expected: succès complet, 0 régression
|
||||
25
_bmad-output/implementation-artifacts/spec-securite-c1-c4.md
Normal file
25
_bmad-output/implementation-artifacts/spec-securite-c1-c4.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
status: done
|
||||
created: 2026-08-26
|
||||
title: Correctifs sécurité critique C1–C4
|
||||
---
|
||||
|
||||
# Spec : Correctifs sécurité C1–C4
|
||||
|
||||
## Contexte
|
||||
Audit de sécurité du 2026-08-26 ([rapport](../audit-securite-2026-08-26.md)). Correction des 4 constats critiques.
|
||||
|
||||
## Tâches
|
||||
1. **C1 — Secrets suivis par git** : `git rm --cached` sur `.env.production`, `.env.ionos`, `data/provider_settings.json`, `translations.db` ; compléter `.gitignore`. (Rotation des secrets + purge d'historique = action manuelle utilisateur, hors scope code.)
|
||||
2. **C2 — Path traversal URL** : dans `routes/translate_routes.py::download_from_url`, assainir le filename issu de `Content-Disposition`/URL (Path().name, contrôle-chars, length cap, fallback `downloaded_file`).
|
||||
3. **C3 — SSRF par redirection** : remplacer `follow_redirects=True` par une boucle manuelle (≤5 hops) qui revalide schéma + `_is_ssrf_risk()` à chaque hop.
|
||||
4. **C4 — Cleanup destructeur** : dans `middleware/cleanup.py::cleanup`, lire toutes les clés de chemin (`input_path`, `file_path`, `output_path`) et n'appliquer la suppression orpheline qu'au-delà d'un âge plancher (`orphan_grace_seconds`, défaut 900 s).
|
||||
|
||||
## Critères d'acceptation
|
||||
- **AC1** : Étant donné un `Content-Disposition: filename="../../evil.xlsx"`, quand `download_from_url` s'exécute, alors le fichier est écrit dans `UPLOAD_DIR` avec un nom sans traversée.
|
||||
- **AC2** : Étant donné une URL publique qui redirige (302) vers `http://169.254.169.254/`, quand `download_from_url` s'exécute, alors une erreur `ssrf_blocked` est levée.
|
||||
- **AC3** : Étant donné un fichier récent (< 15 min) non tracé dans Redis, quand `cleanup()` s'exécute, alors le fichier n'est PAS supprimé ; au-delà du plancher il l'est.
|
||||
- **AC4** : `git ls-files` ne contient plus `.env.production`, `.env.ionos`, `translations.db`, `data/provider_settings.json`.
|
||||
|
||||
## Tests
|
||||
- Tests unitaires pour le filename sanitizer, la boucle de redirection, et la logique orpheline du cleanup.
|
||||
Reference in New Issue
Block a user