Skip to content

Production security review — 2026-08-12 (OGUR-76)

Verdict: three defects found (two release-blocking, one high-severity availability issue raised in PR review), all fixed and verified against the live deployment. Every item in the issue's scope is verified in production; nothing is left unchecked. Release may proceed.

Scope: the deployed app at https://ogur.fly.dev, not the code alone. Reviewed sessions, authorization, cross-client data access, secrets, LLM spend controls, API response exposure, input validation, and dependencies. Reviewed at 1ab0b2ab; fixes deployed 2026-08-12.

Every status code below was observed against the live deployment. Nothing in this document is inferred from reading the code alone unless it says so.


1. Findings

# Area Severity Status
F1 11 routes served landscape data unauthenticated Release-blocking Fixed, verified live
F2 5 unauthenticated POSTs bypassed the LLM spend ceiling Release-blocking Fixed, verified live
F3 session_cookie_secure defaulted to False Medium Fixed
F4 /docs, /redoc, /openapi.json public Medium Fixed, verified live
F5 No rate limiting on POST /api/auth/login Low Accepted, not fixed (§8)
F6 14-day non-rotating session; mismatched clearing cookie Low Partly fixed (§8)
F7 Dependency advisories Mixed Fixed except one non-reachable (§6)
F8 smoke_container.sh has been non-functional since OGUR-65 Low Not fixed (§9)
F9 Anonymous login could OOM the whole machine High (availability) Fixed (§7) — raised in PR review

F1 — Eleven routes served landscape data with no authentication

The ten routes in ogur/api/routes/briefings.py and the comparative-evidence route in ogur/api/routes/evidence.py carried no auth dependency. Both routers are registered in production (ogur/api/app.py).

Observed before the fix, anonymously, against ogur.fly.dev:

404  /api/briefing/gns561-cca-autophagy-001
     {"detail":"No briefing found for landscape 'gns561-cca-autophagy-001'"}
404  /api/landscapes/gns561-cca-autophagy-001/evidence/comparative
     {"detail":"… has no comparative-evidence config. v1 supports: ['immunology-001']"}

These are content answers, not authorization refusals. The routes were reachable; they returned 404 only because the production content.db holds no Briefing rows and no evidence config. Had either been populated — which is the normal end state of running the pipeline — the content would have been served to any anonymous caller who guessed a landscape_id. The second response additionally published the configured-landscape list to an anonymous caller.

This is the gap in mvp-scope.md §2 item 3 ("every client-reachable surface real, gated, or deleted"): these surfaces were neither.

Fix. The six GET routes now take get_current_user + require_landscape_access, the pair routes/signals.py and routes/query.py already use. A grant-miss answers with the same no-leak detail as the report routes, so an ungranted id is indistinguishable from an id this deployment does not serve. Grants are checked against the base landscape_id, never the composite cache id ({landscape_id}-{drug}-overview).

Verified live, post-deploy:

401  /api/briefing/gns561-cca-autophagy-001                              {"detail":"Not authenticated"}
401  /api/briefing/…/drug/pembrolizumab                                  {"detail":"Not authenticated"}
401  /api/briefing/…/drug/pembrolizumab/overview                         {"detail":"Not authenticated"}
401  /api/briefing/…/drug/pembrolizumab/trials                           {"detail":"Not authenticated"}
401  /api/briefing/…/drug/pembrolizumab/competitive                      {"detail":"Not authenticated"}
401  /api/landscapes/gns561-cca-autophagy-001/evidence/comparative       {"detail":"Not authenticated"}
401  /api/landscapes/immunology-001/evidence/comparative                 {"detail":"Not authenticated"}

The immunology-001 probe matters: that is the one landscape with a real evidence config, so it is the case that would actually have returned data.

F2 — Five unauthenticated POSTs bypassed the OGUR-65 spend ceiling

POST /api/briefing/{id}, and the .../generate routes for drug briefing, overview, trials and competitive, each returned 202 and fanned a BackgroundTask into the Synthesizer or an analyzer's anthropic.Anthropic().messages.create — with no authentication and no spend reservation.

The ceiling itself is correctly built and well-tested. Its problem was coverage, not mechanism: it was wired to exactly two endpoints (routes/query.py, routes/explore.py) and these five sat outside it. An anonymous caller could have billed the account without limit, in unbounded parallel, the moment ANTHROPIC_API_KEY was set.

Nothing was being spent because that key is deliberately absent in production (fly.toml). That is a deferral, not a control — and OGUR-77 is the step that injects it.

Fix. The five routes are deleted, not gated. On-demand generation is not an MVP-1 surface (mvp-scope.md §6) and the offline entry points remain (scripts/briefing/generate_briefing.py, generate_drug_briefing.py, analyze_drug.py). Deleting removes the reachable path outright rather than adding a reservation to a BackgroundTask on release eve.

Verified live, post-deploy:

405  POST /api/briefing/gns561-cca-autophagy-001
404  POST /api/briefing/…/drug/pembrolizumab/generate
404  POST /api/briefing/…/drug/pembrolizumab/overview/generate
404  POST /api/briefing/…/drug/pembrolizumab/trials/generate
404  POST /api/briefing/…/drug/pembrolizumab/competitive/generate

ogur/config.py defaulted the flag off, relying on fly.toml's SESSION_COOKIE_SECURE=true. Production was correct, but the default meant any other deployment — a second environment, a container run by hand, a forgotten env var — shipped a session cookie readable off plain HTTP. Now defaults True; local HTTP dev opts out via .env.

F4 — Interactive docs and the OpenAPI schema were public

Confirmed 200 on all three in production before the fix. This published the full route map, including the F1 routes, to anyone. Now behind api_docs_enabled, default off.

Verified live: all three now return the SPA shell with zero schema content (grep -c '"openapi"' → 0). They return 200, not 404, because non-/api paths fall through to the SPA catch-all by design — the schema is gone, which is the property that matters. (My own plan predicted 404 here; the catch-all is why it is 200.)


2. Cross-client data access

tests/unit/api/test_authorization.py covers the property directly and now covers the F1 routes too: an ungranted landscape must answer exactly like an unserved one, so a probe learns nothing about what exists on this deployment. The new cases assert on the 404 body, not just the status — each of these routes has its own content-shaped 404 ("No briefing found for landscape X") and returning one of those to an ungranted caller would itself confirm existence.

These tests were replayed against the pre-fix handler: 14 of 17 fail, and all pass against the fix. They are not passing vacuously.

Live two-identity verification against the deployment: completed, see §10.


3. Secrets

Scanned the production bundle and stylesheet fetched from ogur.fly.dev:

  • No key-shaped literals (sk-ant-…, sk-…, AIza…, ghp_…, AKIA…, xox[baprs]-, PEM private-key headers, JWTs). The only hit for a secret-shaped identifier is React's internal __SECRET_INTERNALS_D.
  • No VITE_ variables baked in. The single production import.meta.env read is VITE_DEMO_SURFACES, a boolean feature gate.
  • Sourcemaps are not exposed. A .js.map request returns 200, which looks like a leak and is not: the body is index.html (1,016 bytes, text/html) served by the SPA catch-all, and the bundle carries no sourceMappingURL. Recorded because the naive check reports a false positive here.
  • The repo .env holds live keys but is gitignored and in .dockerignore; it sits above Vite's envDir so it cannot be inlined.

Error responses carry HTTPException details only — no tracebacks anywhere, no custom 500 handler, debug=False, echo=False.


4. LLM spend controls

The ceiling is a reserve-then-settle rollup keyed by (user_id, UTC day), atomic against concurrent bursts, with the reservation sized to the request's worst case. After F2, the only two routes that can reach Anthropic are POST /api/ask and POST /api/explore/decompose, and both take a reservation before the call and settle in a finally. Both refuse anonymously in production (401, verified live).

Stated limitation. With ANTHROPIC_API_KEY absent from production, the ceiling could not be exercised end-to-end against the live app. What backs it is the code path plus tests/unit/api/test_spend_ceiling.py (14 cases, including per-identity isolation, the UTC-day boundary, concurrent reservations, and 429-before-LLM-call). This is a code-path proof, not a live spend test, and should not be reported as the latter.


5. Input validation

Vector Result
SQL injection None. All queries are parameterized SQLModel select().where(). The only f-string SQL (store/database.py) interpolates a module-level constant, never request data.
Path traversal — report packs landscape_id never reaches the filesystem: pack_for_landscape is an exact dict lookup keyed on ids read from inside each configured pack. Now pinned by a regression test, because it was safe by construction and untested — a refactor to packs_dir / f"{id}.json" would have made it an arbitrary-file read silently.
Path traversal — SPA static Guarded by resolve() + is_relative_to(dist) + is_file(), test-covered.
Free text → LLM Both paid endpoints cap input at 4,000 chars, check the grant first, and reserve spend before the call. No prompt-injection sanitization — inherent to the feature; the bounds are the length cap, the grant, and the ceiling.
Free text → DB Feedback capped at 10,000 chars; author taken from the session, never the body.
Filter params limit bounded 1..500, offset ≥ 0.
CORS No CORSMiddleware registered; no ACAO header returned cross-origin. Same-origin only.

6. Dependencies

Python — audited the exact locked versions against OSV.dev (pip-audit could not build its isolated venv in this environment; OSV is the same database it queries). 7 of 37 production packages were flagged; now 0.

Package Was Now Why it mattered
starlette 1.0.0 1.6.0 HIGH: request.form() limits silently ignored for urlencoded bodies. MODERATE: missing Host-header validation poisons request.url.path and bypasses path-based checks — in the HTTP layer of a public app
lxml 6.0.2 6.1.1 HIGH: XXE by default in iterparse() (ingestion path, parses external XML)
idna 3.11 3.18 MODERATE: DoS on crafted input
pydantic-settings 2.13.1 2.15.0 MODERATE (feature unused here)
pypdf, soupsieve, click likewise

Frontendnpm audit: 13 advisories → 7. Nothing in the remaining 7 reaches a browser: vitest (critical, but only with the Vitest UI server listening), vite, esbuild and transitives are build-time only, and each needs a semver-major bump.

One residual on a shipping package, stated plainly: react-router-dom 6.30.4 still carries a moderate open-redirect advisory. The whole 6.x line and 7.x through 7.17.0 are affected, so clearing it means the v7 migration — not a release-eve change. It has no reachable sink here: every navigation target is a hardcoded literal or encodeURIComponent'd, and LoginPage's returnTarget already rejects //-prefixed targets. The companion SSR-hydration advisory does not apply; this is a client-only SPA.


7. F9 — anonymous login could OOM the machine (found in review, fixed)

Raised reviewing this PR, and it corrects the framing of F5 above. Login was not only a password-guessing surface on this deployment — it was an availability one, and the review originally understated it.

ogur/security.py fixes N=2^17, r=8, so each scrypt call transiently allocates 128*r*N = 128 MiB. The semaphore admitted 4 concurrent calls. fly.toml gives the machine 512 MiB. 4 × 128 MiB = 512 MiB — the entire VM, on top of uvicorn and the two lru-cached packs. An anonymous caller reached it with four concurrent POST /api/auth/login, and because the timing-equalising dummy hash runs a full scrypt for an unknown email, no valid account was needed. Below the OOM threshold the same queue starves Starlette's 40-thread pool, stalling /health and the report routes with it.

The semaphore and the dummy hash are both correct work; the bound was simply chosen without reference to the machine it runs on.

Fixed by lowering _SCRYPT_CONCURRENCY to 2 — peak scrypt memory 256 MiB, half the VM, leaving the rest to the app. Login throughput halves, which two client identities cannot observe. Chosen over resizing the machine because it costs nothing and does not touch §2 scope.

test_scrypt_peak_memory_fits_the_deployed_machine now parses memory out of fly.toml and asserts 128*r*N * concurrency ≤ machine/2, so the two numbers cannot drift apart again — including via a machine resize, which does not touch Python and would otherwise reintroduce this silently.


8. Accepted, not fixed

Real, out of MVP-1 scope, and carrying more deadline risk than they remove. Each needs a follow-up ticket.

  • F5 — no login rate limiting. POST /api/auth/login is unthrottled, so password guessing is bounded only by scrypt's cost. The rate limiter is still deferred, but the more serious half of this finding was fixed — see F9.
  • F6 — session lifetime. 14-day fixed TTL, no rotation on login, no idle timeout, no reaper for expired rows (they are deleted lazily on read). The mismatched clearing cookie was fixed: delete_cookie now carries the same attributes as set_cookie.
  • No CSRF token. SameSite=Lax is the only CSRF control. Lax blocks cross-site POST, and after F2 there are no unauthenticated state-changing routes, so the exposure is small — but it is one mitigation deep.
  • No HSTS / CSP / X-Frame-Options / X-Content-Type-Options. Fly forces HTTPS, so HSTS is the smallest gap; a CSP is the one worth adding next.

9. F8 — the local pre-deploy gate has not been running

scripts/smoke/smoke_container.sh fails at criterion 2 and has since OGUR-65: it fetches /api/explore/landscapes anonymously, and that route has required auth since. The script never logs in (zero references to auth/login). Pre-existing, unrelated to this change, and not a security defect — but it means the local container gate has been passing nobody and blocking nothing. smoke_production.sh does handle credentials and is the one that covers these criteria properly.

The container image was still verified for this review, by probing it directly rather than through the broken script (§1, F1/F2 rows were confirmed against the local image before deploying).


10. Authenticated verification against production — completed

The two acceptance items that needed a real client session, run 2026-08-12 against https://ogur.fly.dev as the two provisional client identities.

1. Cookie flags, read off a live login response (token redacted, attributes verbatim):

HTTP/2 200
set-cookie: ogur_session=<REDACTED>; HttpOnly; Max-Age=1209600; Path=/; SameSite=lax; Secure

HttpOnly, Secure, SameSite=lax and Path=/ all present on the deployment, not only in config. Max-Age=1209600 is the 14-day TTL noted in §8.

2. Cross-client isolation, both directions, across all three per-landscape surfaces:

Identity Landscape report figures feedback
Genfit gns561-cca-autophagy-001 (own) 200 200 200
Genfit polygon-cd8-ami-001 (other) 404 404 404
Polygon polygon-cd8-ami-001 (own) 200 200 200
Polygon gns561-cca-autophagy-001 (other) 404 404 404

Every cross-client body is the no-leak detail — {"detail":"no report for landscape '<id>'"} — never a content-shaped one.

3. The F1 routes under a real session, which is the discrimination the fix exists to make:

genfit → own      /api/briefing/gns561-…            404 {"detail":"No briefing found for landscape '…'"}
genfit → other    /api/briefing/polygon-cd8-ami-001 404 {"detail":"no report for landscape '…'"}

Both 404, different bodies: the granted caller reaches the handler and is told the content is absent; the ungranted caller is refused before the handler runs. The immunology-001 evidence-config roster is not disclosed to a session without that grant.

4. Full production smokescripts/smoke/smoke_production.sh, all eleven criteria:

PROD SMOKE PASS: https://ogur.fly.dev serves gns561-cca-autophagy-001 and
polygon-cd8-ami-001, API + frontend, warm, packs verified.
OK 11: single started machine, warm health probes 60s apart (0.037724s, 0.037650s)

Nothing in the issue's scope is now unverified against the deployment. What remains open is listed in §8 and §9 as accepted or deferred, not as unchecked.


11. What was verified clean

Report / figures / feedback / signals / roster / classes all refuse anonymously (401) — the OGUR-65 gate holds. Password hashing is scrypt N=2¹⁷ with a bounded semaphore and a timing-equalizing dummy verify; session tokens are 256-bit secrets.token_urlsafe(32) stored SHA-256 hashed, with server-side expiry authoritative. Login returns one non-specific 401 for both unknown-email and wrong-password. The client bundle was rebuilt byte-identically before and after the frontend edits, confirming the demo surfaces never shipped to clients in the first place.

Backend suite: 3087 passed, 0 failed, on the upgraded dependency stack. make lint clean.