CAI Technology
Menu ☰
leta · · 16 min read

From 10 vulnerabilities to 0 in 5 days: pre-production hardening of a legal platform

How we identified and fixed 10 vulnerabilities on Leta between v2.32 and v2.33: header forging, OAuth CSRF, CAPTCHA bypass, brute-force via XFF spoof, plus reusable patterns.

CAI Technology · Last reviewed: 4/30/2026
From 10 vulnerabilities to 0 in 5 days: pre-production hardening of a legal platform

From 10 vulnerabilities to 0 in 5 days: pre-production hardening of a legal platform

On 21 April 2026 we received the report of an external security review of www.lege365.ro — our Leta legal platform. Five days before the production go-live scheduled for 26 April. The report listed ten vulnerabilities, four of them with critical impact on authentication and authorisation. We had five days to close all of them.

This article walks through each of the ten issues, how it was detected, and the fix pattern we used. Many are bug classes you will meet in any FastAPI/Nginx/Kong stack with OAuth, so we share concrete solutions, not just an enumeration.

TL;DR

Technical context

Leta is an AI legal platform for Romanian lawyers. The stack includes FastAPI (Python) as backend, Nginx as front-edge reverse proxy, Kong API Gateway for per-corpus routing, and a PostgreSQL with pgvector plus 277,341 legislative acts plus 185,603 acts from the Official Gazette. OAuth for SSO into institutional accounts. Lawyers access the platform after authentication; a large share of traffic is read-only public for SEO (139,872 indexed URLs).

The review was done by a third-party team with black-box access to www.lege365.ro and gray-box access to the architecture documentation. They worked for one week. They found ten issues.

V1 — Auth bypass through X-Consumer-Username forgery

How it worked. The FastAPI backend consumes the X-Consumer-Username header injected by Kong after successful authentication. If the header is present, the backend treats the user as authenticated under that username. The business logic expects Kong to clean or inject this header.

Vulnerability. The front-edge Nginx did not strip the header. An attacker could send curl -H "X-Consumer-Username: admin" directly to www.lege365.ro and — if the request somehow bypassed Kong (a route going directly to the backend through Nginx) — the backend would treat the user as admin. Full auth bypass.

Fix. Nginx explicit strip on ingress for six “Kong-only” headers:

# on the public www.lege365.ro server block
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 "";

Reusable pattern. Any header injected by an internal proxy (Kong, Traefik, HAProxy) must be explicitly stripped at the public perimeter. The list of “internal” headers is part of the security contract — document it in the README.

V2 — Open redirect and XSS through ?redirect=

How it worked. The application supported a ?redirect=/some/path parameter to redirect after login. The validation was just if redirect: response = RedirectResponse(redirect).

Vulnerability. The attacker could set ?redirect=https://evil.example.com/phish.html or ?redirect=javascript:alert(document.cookie). The first equals phishing; the second equals stored XSS in the audit logs that displayed the redirect value.

Fix. A _safeRedirect() function that accepts only relative paths on the own domain:

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

Strict whitelist regex, refuse on suspicious patterns, fallback to root. Do not use a blacklist of protocols (javascript:, data:, vbscript:, etc.) — blacklists are incomplete by definition.

V3 — OAuth CSRF with weak state mismatch

How it worked. OAuth 2.0 uses an opaque state parameter to bind the authorisation request to the callback. Our implementation generated state with secrets.token_urlsafe(32) (correct) and verified it on callback with if state == cookie_state (the problem).

Vulnerability. Comparison with == is non-constant time. An attacker with fine-grained timing access (for example an attacker on the user’s LAN) can progressively recover bytes of the valid state through timing attacks — and then mount a full CSRF that chains the attacker’s authorisation with the victim’s session.

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)

# at callback
if not verify_oauth_state(request.query_params.get("state"), cookie_state):
    raise HTTPException(400, "OAuth state mismatch")

Reusable pattern. Any secret comparison (token, MAC, signature) uses hmac.compare_digest in Python or crypto.timingSafeEqual in Node or subtle.ConstantTimeCompare in Go. Comparing strings with == is a timing leak path, always.

How it worked. The session cookie had historically been set with SameSite=none to accept cross-site requests from partner embeds. The embeds had been retired long ago, but the configuration remained.

Vulnerability. SameSite=none plus Secure allows the cookie to be sent on any cross-site request. A malicious site could POST to lege365.ro/api/sensitive-action with the user’s cookie attached automatically.

Fix. SameSite=lax everywhere, with an explicit exception only on temporary OAuth cookies (state, code_verifier) that genuinely need to survive a cross-site redirect. Plus a CSRF token in the page for sensitive POSTs.

response.set_cookie(
    "session_id",
    value=session_token,
    httponly=True,
    secure=True,
    samesite="lax",
    max_age=86400,
)

V5 — Internal endpoint /api/mo-p4-firme exposed publicly

How it worked. An endpoint built for internal consumption from other platform services was reachable on the public Nginx. It returned company data from part P4 of the Official Gazette.

Vulnerability. Enumerable endpoint (paginated), no authentication, no Nginx rate limit. An attacker could vacuum the entire public dataset in a few hours. The data was public per record, but bulk aggregation is a different problem — and breaches the source’s terms (the Official Gazette).

Fix. Nginx deny all at the public ingress; access stays through Kong with authentication:

location /api/mo-p4-firme {
    deny all;
    return 403;
}

Reusable pattern. An enumerable endpoint that returns public data may be public — but it must be intentional, with explicit rate limit, hard-cap pagination offsets, and ToS forbidding commercial scraping.

V6 — CAPTCHA bypass through response leak

How it worked. The signup endpoint had hCaptcha. For debugging, the backend validator responded with {"captcha_valid": true/false, "expected": "abc123"}.

Vulnerability. The expected field was included in the JSON response. The attacker read the value, sent it in the next request, full bypass. Decorative CAPTCHA.

Fix. Two changes:

  1. CAPTCHA mandatory, validated server-side strictly through the hCaptcha API; no response ever contains expected.
  2. Fail closed: if the call to the hCaptcha API fails (timeout, 5xx), we refuse signup — we do not accept “I assume it was 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/* accessible without authentication

How it worked. Routes serving DOCX templates for contracts (model documents) were accessible without authentication and without quota check. Templates were part of the paid “Pro” plan.

Vulnerability. Full bypass of the monetisation plan — and worse, we were breaching agreements with the editorial authors of the templates (who received royalties on downloads).

Fix. A require_auth_and_quota decorator applied to all /template/* routes:

@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 — Public Swagger in production

How it worked. FastAPI auto-generates /docs (Swagger UI) and /openapi.json from route decorators. Useful in development, not used in production.

Vulnerability. /openapi.json in production exposes the entire API structure — every route, every parameter, every Pydantic schema, every payload example. To an attacker, it is a complete map of the attack surface. Plus examples that leak details about the internal data structure.

Fix. In production, docs_url=None and 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 through X-Forwarded-For spoof

How it worked. The login endpoint had a per-IP rate limit: 5 attempts/minute/IP. The IP came from the first element of the X-Forwarded-For header injected by Nginx — which, in correct architecture, Nginx always rewrites.

Vulnerability. Because of V1 (header forgery), the attacker could inject their own X-Forwarded-For: 1.2.3.4. The rate limit was applied per “1.2.3.4”, and the attacker rotated to a new fake IP every five attempts. Rate limit completely bypassed — full-speed brute-force.

Fix. A _client_ip() function that uses only X-Real-IP set by Nginx (after V1 fix, guaranteed clean) and ignores X-Forwarded-For:

def _client_ip(request: Request) -> str:
    # X-Real-IP is set by the front-edge Nginx and cannot be spoofed after V1 fix
    real_ip = request.headers.get("x-real-ip")
    if real_ip:
        return real_ip
    # fallback to peer address (when no proxy is present)
    return request.client.host if request.client else "unknown"

Reusable pattern. Detecting the real client IP requires explicit cooperation from the proxy layer. X-Forwarded-For is by definition a client-controllable field — Nginx rewrites it in correct setup, but if there is any path where the request reaches the backend without going through Nginx, FAIRA falls. X-Real-IP set explicitly by Nginx at ingress and nowhere else is more robust.

How it worked. OAuth cookies (state, PKCE code_verifier) were set with path=/, meaning they were attached to all requests towards lege365.ro. Plus, after consumption in the callback, they were not deleted.

Vulnerability. Two related problems:

  1. OAuth cookies leak to any script on the page — a minor XSS on /some/path could steal the in-progress OAuth state.
  2. Reuse: if an attacker held the state, they could use it again in a second callback.

Fix. Two simple changes:

# scope cookie strictly to OAuth routes
response.set_cookie(
    "oauth_state",
    value=state,
    httponly=True,
    secure=True,
    samesite="lax",
    path="/auth/oauth",  # not "/"
    max_age=600,  # 10 min
)

# at callback, delete cookie after successful consumption
@router.get("/auth/oauth/callback")
async def oauth_callback(...):
    # ... validation ...
    response = RedirectResponse(_safeRedirect(redirect_target))
    response.delete_cookie("oauth_state", path="/auth/oauth")
    response.delete_cookie("oauth_code_verifier", path="/auth/oauth")
    return response

Lessons learned — reusable patterns

After the five days of remediation, we extracted a few rules we now apply to all CAI Technology platforms:

1. “Internal” headers — explicit list. Any header injected by Kong/Traefik/HAProxy must be explicitly stripped at the ingress Nginx. The list is part of the security spec, documented.

2. Compare secrets = compare_digest, always. No exception. OAuth state, MAC, password, token — any secret string comparison uses constant-time compare.

3. Fail closed on external validations. CAPTCHA, OAuth introspection, license check — if the external API fails, default = refuse. Never “I assume it was OK.”

4. Real IP = X-Real-IP, not X-Forwarded-For. Nginx sets one or the other; choose one, guarantee cleanliness, never use raw XFF from the client.

5. Swagger is dev-only. docs_url=None in production. If you need external OpenAPI (B2B partners), publish it in a clean controlled location.

6. OAuth cookie = path scoped + single-use. No temporary cookie stays on path=/ and no OAuth cookie survives a successful callback.

Operational result

On 26 April 2026, Leta v2.33.0 went into production with zero known open vulnerabilities. The follow-up review (10 May) confirmed no fix had regressed. Performance was not impacted — overhead from compare_digest and _client_ip is below one microsecond per request.

The hardening process is now standard across all our platforms: external blind review three weeks before production, plus internal QA, plus five days dedicated to fixes and retest.

Next steps

If you are working on a FastAPI/Nginx platform in pre-production and want to run a similar review — write to contact. We have a hardening kit (47-point checklist) we use internally and can deliver as a review for clients.

Related reading: On-premise SIEM with local LLM (how to monitor logs to detect attacks in progress) · Why a law firm cannot use Auth0 (compliance for authentication) · Leta page.

References

We start with a 30-minute conversation.

Free AI-readiness audit for companies with 50+ employees. We reply within 24 hours.