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
F3 — session_cookie_secure defaulted to False¶
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 productionimport.meta.envread isVITE_DEMO_SURFACES, a boolean feature gate. - Sourcemaps are not exposed. A
.js.maprequest returns200, which looks like a leak and is not: the body isindex.html(1,016 bytes,text/html) served by the SPA catch-all, and the bundle carries nosourceMappingURL. Recorded because the naive check reports a false positive here. - The repo
.envholds live keys but is gitignored and in.dockerignore; it sits above Vite'senvDirso 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 |
Frontend — npm 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/loginis 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_cookienow carries the same attributes asset_cookie. - No CSRF token.
SameSite=Laxis 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 smoke — scripts/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.