fix(security/web) P0: gate handler StopIteration on apex + 404 on portals
⚠ Incident — 2026-05-21 (cont. of MR !33 (merged))
After MR !33 (merged) (the original `sovereignstrike.com/plan` leak fix) was deployed, two regressions surfaced when the handler ran behind Cloudflare Tunnel:
- `/` and `/index.html` crashed with `StopIteration` → connection closed → cloudflared logs `EOF` → user sees 502 Bad Gateway. Affects every visitor hitting the apex (which was every mobile user since CF caches the apex differently than internal paths).
- `/cto`, `/art`, `/plan`, `/studio`, … returned 404 because the gate's serve-direct flow tried to read `/cto/index.html` literally instead of `/cto_portal/index.html` (which is what the rewrite chain does).
Symptoms in the cloudflared log: ``` ERR error="Unable to reach the origin service ... EOF" connIndex=2 originService=http://127.0.0.1:8788 ```
Root cause
MR !33 (merged) added the 11 internal portal prefixes to `_PREVIEW_PREFIXES` to enforce authentication on them. But that tuple controlled two unrelated concerns:
| Concern | Should drive |
|---|---|
| "must require authentication" | _PREVIEW_PREFIXES |
| "is served from disk at /index.html" | (was implicit, conflated) |
For `/press`, both are true. For `/cto`, only the first is — the actual file lives at `/cto_portal/index.html` via the elif chain farther down `do_GET`. The post-gate code returned BEFORE reaching that rewrite block.
For `/` and `/index.html`, neither serving strategy matches; the prefix lookup raised `StopIteration`.
Fix
- New `_PREVIEW_SERVE_DIRECT_PREFIXES = {"/press"}` — the only path that genuinely lives as a directory in `docs/`. Drives the serving strategy only.
- Apex roots (`/`, `/index.html`) and internal portals (`/cto`, `/art`, `/plan`, `/studio`, …) fall through to the standard rewrite chain after auth passes.
- New `_gate_passed_for_path` instance flag prevents double-prompting with `CMO_WEB_AUTH` on top of `LANDING_AUTH`.
Test plan
-
`uv run pytest -q` → 473 passed (+1 regression test enforcing the serve-direct split) -
Local curls (against the natively-served origin): - `/, /cto, /cto/, /plan, /plan/, /art, /art/, /studio` → 200 (with auth)
- `/press` → 301 → `/press/` → 200 (with auth)
- All without auth → 401
-
Playwright navigated `/cto` end-to-end via Cloudflare Tunnel and got `ERR_INVALID_AUTH_CREDENTIALS` (= origin served basic-auth challenge, NOT 502) -
Post-merge: confirm mobile flow (the original reproducer)
Severity
P0 — the production cockpit was unusable from any client that hit the apex first (i.e. every mobile + every fresh browser session).