De la 10 vulnerabilități la 0 în 5 zile: hardening-ul pre-producție al unei platforme juridice
Cum am identificat și remediat 10 vulnerabilități pe Leta între v2.32 și v2.33: header forging, OAuth CSRF, CAPTCHA bypass, brute-force prin XFF spoof, plus pattern-uri reutilizabile.
De la 10 vulnerabilități la 0 în 5 zile: hardening-ul pre-producție al unei platforme juridice
Pe 21 aprilie 2026 am primit raportul unui review extern de securitate pe www.lege365.ro — platforma noastră juridică Leta. Cinci zile înainte de go-live producție programat pentru 26 aprilie. Raportul lista zece vulnerabilități, dintre care patru cu impact critic pe autentificare și autorizare. Am avut cinci zile să le închidem pe toate.
Acest articol descrie fiecare dintre cele zece probleme, cum a fost detectată, și pattern-ul de fix folosit. Multe sunt clase de bug-uri pe care le veți întâlni în orice platformă FastAPI/Nginx/Kong cu OAuth — așa că împărtășim soluțiile concrete, nu doar enumerarea.
TL;DR
- Zece vulnerabilități identificate; șapte critice + trei majore. Toate rezolvate în cinci zile, fără slip de producție.
- Pattern-urile cele mai periculoase: header forgery prin proxy, brute-force amplificat prin spoof
X-Forwarded-For, OAuth state CSRF cu comparație non-constantă. - Fix-urile sunt simple ca implementare — sub zece linii fiecare. Greutatea e în detecție și în arhitectura corectă a perimetrului.
- La final, Leta v2.33.0 a intrat în producție pe 26 aprilie cu zero vulnerabilități cunoscute deschise.
Contextul tehnic
Leta este o platformă AI legislativă pentru juriști din România. Stack-ul include FastAPI (Python) ca backend, Nginx ca reverse proxy front-edge, Kong API Gateway pentru rutare per corpus legal, și un PostgreSQL cu pgvector + 277.341 acte legislative + 185.603 acte din Monitorul Oficial. OAuth pentru SSO către conturi instituționale. Operatori juridici accesează platforma după autentificare; un volum mare al traficului e public read-only pentru SEO (139.872 URL-uri indexate).
Review-ul a fost făcut de o echipă terță, cu acces black-box la www.lege365.ro și acces gray-box la documentația arhitecturii. Au stat o săptămână. Au găsit zece probleme.
V1 — Auth bypass prin X-Consumer-Username forgery
Cum funcționa. Backend-ul FastAPI consumă headerul X-Consumer-Username injectat de Kong după autentificare reușită. Dacă headerul există, backend-ul consideră utilizatorul autentificat ca acel username. Logica de business așteaptă ca Kong să curățe sau să injecteze acest header.
Vulnerabilitatea. Nginx-ul front-edge nu strip-uia headerul. Un atacator putea trimite direct curl -H "X-Consumer-Username: admin" către www.lege365.ro și — dacă request-ul ocolea cumva Kong (de exemplu o rută care merge direct la backend prin Nginx) — backend-ul considera utilizatorul ca admin. Auth bypass complet.
Fix. Nginx strip explicit la ingress șase headere “Kong-only”:
# pe server block-ul public www.lege365.ro
proxy_set_header X-Consumer-Username "";
proxy_set_header X-Consumer-Id "";
proxy_set_header X-Consumer-Custom-Id "";
proxy_set_header X-Anonymous-Consumer "";
proxy_set_header X-Credential-Identifier "";
proxy_set_header X-Authenticated-Userid "";
Pattern reutilizabil. Orice header pe care îl injectează un proxy intern (Kong, Traefik, HAProxy) trebuie strip-uit explicit la perimetrul public. Lista de headere “interne” e parte din contractul de securitate — documentați-o în README.
V2 — Open redirect și XSS prin ?redirect=
Cum funcționa. Aplicația suporta un parametru ?redirect=/some/path pentru a redirige post-login. Validarea era doar if redirect: response = RedirectResponse(redirect).
Vulnerabilitatea. Atacatorul putea seta ?redirect=https://evil.example.com/phish.html sau ?redirect=javascript:alert(document.cookie). Primul = phishing; al doilea = stored XSS în log-urile de audit care arătau valoarea redirect-ului.
Fix. Funcția _safeRedirect() care acceptă doar path-uri relative pe domeniul propriu:
import re
_SAFE_PATH = re.compile(r"^/[a-zA-Z0-9_\-/?&=.]*$")
def _safeRedirect(target: str | None, fallback: str = "/") -> str:
if not target:
return fallback
if not _SAFE_PATH.match(target):
return fallback
if "//" in target or target.startswith("/auth/oauth"):
return fallback
return target
Whitelist regex strict, refuz pe pattern dubios, fallback la rădăcină. Nu folosiți blacklist de protocoale (javascript:, data:, vbscript:, etc.) — listele sunt incomplete prin definiție.
V3 — OAuth CSRF cu state mismatch slab
Cum funcționa. OAuth 2.0 folosește un parametru state opaque pentru a lega request-ul de autorizare de callback. Implementarea noastră genera state cu secrets.token_urlsafe(32) (corect) și-l verifica la callback cu if state == cookie_state (problema).
Vulnerabilitatea. Comparația cu == este non-constantă în timp. Un atacator cu acces la timing fin (de exemplu un atacator pe LAN-ul utilizatorului) poate progresiv să descopere bytes din state-ul valid prin atacuri de timing — apoi montează un CSRF complet care înlănțuie autorizarea atacatorului cu sesiunea victimei.
Fix. hmac.compare_digest (constant time):
import hmac
def verify_oauth_state(received: str, expected: str) -> bool:
if not received or not expected:
return False
if len(received) != len(expected):
return False
return hmac.compare_digest(received, expected)
# la callback
if not verify_oauth_state(request.query_params.get("state"), cookie_state):
raise HTTPException(400, "OAuth state mismatch")
Pattern reutilizabil. Orice comparație de secret (token, MAC, semnătură) folosește hmac.compare_digest în Python sau crypto.timingSafeEqual în Node sau subtle.ConstantTimeCompare în Go. Comparația cu == pe stringuri este o cale de scurgere prin timing, mereu.
V4 — CSRF prin cookie SameSite=none
Cum funcționa. Cookie-ul de sesiune fusese setat istoric cu SameSite=none pentru a accepta request-uri cross-site din embedurile partenere. Embedurile au fost retrase de mult, dar config-ul a rămas.
Vulnerabilitatea. SameSite=none plus Secure permite cookie-ului să fie trimis pe orice request cross-site. Un site malițios putea face un POST către lege365.ro/api/sensitive-action cu cookie-ul utilizatorului atașat automat.
Fix. SameSite=lax peste tot, cu o excepție explicită doar pe cookie-urile OAuth temporare (state, code_verifier) care chiar trebuie să supraviețuiască redirectului cross-site. Plus token CSRF în pagină pentru POST-urile sensibile.
response.set_cookie(
"session_id",
value=session_token,
httponly=True,
secure=True,
samesite="lax",
max_age=86400,
)
V5 — Endpoint intern /api/mo-p4-firme expus public
Cum funcționa. Un endpoint creat pentru consum intern din alte servicii ale platformei era accesibil pe Nginx-ul public. Returna informații despre firme din partea P4 a Monitorului Oficial.
Vulnerabilitatea. Endpoint enumerable (paginat), fără autentificare, fără rate limit pe Nginx. Un atacator putea aspira întreaga bază de date publică în câteva ore. Datele erau publice individual, dar agregarea în bulk e o problemă diferită — și încalcă termenii sursei publice (Monitorul Oficial).
Fix. Nginx deny all la ingress public; accesul rămâne via Kong cu autentificare:
location /api/mo-p4-firme {
deny all;
return 403;
}
Pattern reutilizabil. Un endpoint enumerabil care returnează date publice e ok să fie public — dar trebuie să fie comportament intenționat, cu rate limit explicit, paginare cu offset hard-cap, și ToS care să interzică scraping comercial.
V6 — CAPTCHA bypass prin response leak
Cum funcționa. Endpoint-ul de signup avea CAPTCHA hCaptcha. Pentru debugging, validatorul backend răspundea cu {"captcha_valid": true/false, "expected": "abc123"}.
Vulnerabilitatea. Câmpul expected era inclus în response-ul JSON. Atacatorul citea valoarea, o trimitea în request-ul următor, bypass complet. CAPTCHA decorativ.
Fix. Două schimbări:
- CAPTCHA obligatoriu, validat server-side strict cu hCaptcha API, niciun raspuns nu mai conține expected.
- Fail closed: dacă apelul către hCaptcha API eșuează (timeout, 5xx), refuzăm signup-ul — nu acceptăm “presupun că era ok”.
async def verify_captcha(token: str) -> bool:
if not token:
return False
try:
resp = await httpx.post(
"https://hcaptcha.com/siteverify",
data={"secret": HCAPTCHA_SECRET, "response": token},
timeout=5,
)
return resp.json().get("success", False)
except Exception:
# fail closed
return False
V7 — /template/* accesibil fără autentificare
Cum funcționa. Rutele care serveau templateuri DOCX pentru contracte (modele) erau accesibile fără autentificare și fără verificare cuotă. Templateurile erau parte din planul plătit “Pro”.
Vulnerabilitatea. Bypass complet al planului de monetizare — și mai grav, ne încălcam acordurile cu autorii editoriali ai templateurilor (care primeau royalty pe descărcări).
Fix. Decorator require_auth_and_quota aplicat la toate rutele /template/*:
@router.get("/template/{template_id}")
async def get_template(
template_id: str,
user: User = Depends(require_auth_and_quota("template_download")),
):
quota_ok = await user.consume_quota("template_download", count=1)
if not quota_ok:
raise HTTPException(429, "Quota exceeded")
return await fetch_template(template_id)
V8 — Swagger public în producție
Cum funcționa. FastAPI generează automat /docs (Swagger UI) și /openapi.json din decoratoarele rutelor. Util în development, neutilizat în producție.
Vulnerabilitatea. /openapi.json în producție expune întreaga structură a API-ului — toate rutele, toți parametrii, toate schemele Pydantic, toate exemplele de payload. Pentru un atacator, e o hartă completă a suprafeței de atac. Plus exemple care leak informații despre structura datelor interne.
Fix. În producție, docs_url=None și openapi_url=None:
import os
ENV = os.getenv("LEGE365_ENV", "production")
app = FastAPI(
title="Leta API",
docs_url="/docs" if ENV == "development" else None,
redoc_url="/redoc" if ENV == "development" else None,
openapi_url="/openapi.json" if ENV == "development" else None,
)
V9 — Brute-force prin spoof X-Forwarded-For
Cum funcționa. Endpoint-ul de login avea rate limit pe IP: 5 încercări/minut/IP. IP-ul era extras din primul element al headerului X-Forwarded-For injectat de Nginx — pe care, în arhitectura corectă, Nginx îl rescrie întotdeauna.
Vulnerabilitatea. Datorită V1 (header forgery), atacatorul putea injecta un X-Forwarded-For: 1.2.3.4 propriu, controlat. Rate limit-ul aplica per “1.2.3.4”, iar atacatorul rota la fiecare 5 încercări un nou IP fals. Rate limit complet anulat — brute-force la viteză plină.
Fix. Funcția _client_ip() care folosește doar X-Real-IP setat de Nginx (după V1, garantat curat) și ignoră complet X-Forwarded-For:
def _client_ip(request: Request) -> str:
# X-Real-IP e setat de Nginx-ul front-edge și nu poate fi spoofed după fix V1
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip
# fallback la peer address (când nu există proxy)
return request.client.host if request.client else "unknown"
Pattern reutilizabil. Detectarea IP-ului real al unui client necesită cooperarea explicită a layer-ului proxy. X-Forwarded-For este per definiție un câmp client-controllable — Nginx îl rescrie în setup-ul corect, dar dacă există vreo cale prin care request-ul ajunge la backend fără să treacă pe Nginx, FAIRA cade. X-Real-IP setat explicit de Nginx la ingress și nicăieri în altă parte e mai robust.
V10 — OAuth cookie cu path=/ și fără single-use
Cum funcționa. Cookie-urile OAuth (state, PKCE code_verifier) erau setate cu path=/, ceea ce înseamnă că erau atașate la toate request-urile către lege365.ro. Plus, după consumarea lor în callback, nu erau șterse.
Vulnerabilitatea. Două probleme conexe:
- Cookie-urile OAuth leak către orice script de pe pagină — un XSS minor pe
/some/pathputea fura state-ul OAuth-ului în desfășurare. - Reutilizare: dacă un atacator avea state-ul, îl putea folosi din nou într-un al doilea callback.
Fix. Două schimbări simple:
# scope cookie strict pe rutele OAuth
response.set_cookie(
"oauth_state",
value=state,
httponly=True,
secure=True,
samesite="lax",
path="/auth/oauth", # nu "/"
max_age=600, # 10 min
)
# la callback, șterge cookie după consumare reușită
@router.get("/auth/oauth/callback")
async def oauth_callback(...):
# ... validare ...
response = RedirectResponse(_safeRedirect(redirect_target))
response.delete_cookie("oauth_state", path="/auth/oauth")
response.delete_cookie("oauth_code_verifier", path="/auth/oauth")
return response
Ce am învățat — pattern-uri reutilizabile
După cele cinci zile de remediere, am extras câteva reguli pe care le aplicăm acum la toate platformele CAI Technology:
1. Headere “interne” — listă explicită. Orice header injectat de Kong/Traefik/HAProxy trebuie strip-uit explicit la Nginx-ul de ingress. Lista e parte din specificația de securitate, documentată.
2. Compare secret = compare_digest, mereu. Nu există excepție. State OAuth, MAC, parolă, token — orice comparație de string secret folosește comparație constantă în timp.
3. Fail closed pe validări externe. CAPTCHA, OAuth introspection, license check — dacă API-ul extern eșuează, default = refuz. Niciodată “presupun că era ok”.
4. IP real = X-Real-IP, nu X-Forwarded-For. Nginx setează unul sau altul; alegeți unul, garantați curățenia, niciodată nu folosiți XFF brut din client.
5. Swagger e dev-only. docs_url=None în producție. Dacă aveți nevoie de OpenAPI extern (parteneri B2B), publicați-l într-un loc curat și controlat.
6. Cookie OAuth = path scoped + single-use. Niciun cookie temporar nu rămâne pe path=/ și niciun cookie OAuth nu supraviețuiește unui callback reușit.
Rezultat operațional
Pe 26 aprilie 2026, Leta v2.33.0 a intrat în producție cu zero vulnerabilități cunoscute deschise. Review-ul ulterior (10 mai) a confirmat că niciun fix nu a regresat. Performanța nu a fost afectată — overhead-ul de la compare_digest și _client_ip este sub un microsecond per request.
Procesul de hardening e acum standard la toate platformele noastre: review extern blind cu trei săptămâni înainte de producție, plus QA intern, plus cinci zile dedicate pentru fix-uri și retest.
Articole conexe
- De ce un cabinet de avocatură românesc nu poate folosi Auth0 — Schrems II
- Anti-halucinare juridic: chatbot pe 2,8 milioane de documente RO
- SIEM on-premise cu LLM local: AI incident analysis fără să spargi confidențialitatea
- Pillar Leta — asistentul juridic CAI Technology
Pași următori
Dacă lucrați la o platformă FastAPI/Nginx în pre-producție și vreți să rulați un review similar — scrieți la contact. Avem un kit de hardening (checklist 47 puncte) pe care îl folosim intern și îl putem livra ca review pentru clienți.
Articole adiționale: SIEM on-premise cu LLM local (cum monitorizezi log-urile pentru a detecta atacuri în desfășurare) · De ce un cabinet nu poate folosi Auth0 (compliance pentru autentificare) · Pagina Leta.
Referințe
- OWASP Top 10:2021 — A01 Broken Access Control, A07 Identification and Authentication Failures
- OAuth 2.0 Security Best Current Practice (RFC 9700)
- CWE-208 — Observable Timing Discrepancy
- MDN — Set-Cookie SameSite
- Python
hmac.compare_digestdocumentation