## Outcome Read-only audit completed at `HEAD 9d611ea`. **No files were created or modified by me.** V4 can remain unchanged with a V5-only session/CSP branch. ### Blocking gaps in current code 1. **API is Bearer-only; no browser session exists.** - `scripts/health/health_dashboard_server.py:101-123` — `load_api_token()` - `:126-130` — `api_request_is_authenticated()` - `:577-608` — `Handler._handle_api()` requires `Authorization: Bearer …` at `:586`. - This prevents a browser `fetch()` without exposing/storing a Bearer credential. 2. **Both Dashboard V4 and V5 currently receive `connect-src 'none'`.** - `health_dashboard_server.py:507-545` chooses V4/V5 HTML but does not pass route identity into response policy. - `:661-698`, especially `:682-689`, emits one nonce CSP with `connect-src 'none'` for both. - V5 cannot call same-origin `/api/v1/*` until its HTML response is changed to **only** `connect-src 'self'`. 3. **There is no V5 API client yet.** - `scripts/health/assets/health-assets/dashboard-v5.js:1-985` consumes embedded `#health-dashboard-bundle`; audit found no `fetch`, `Authorization`, `Bearer`, `localStorage`, `sessionStorage`, or `/api/v1` usage. - `dashboard_v5/render.py:95-96` is the V5 HTML/script inclusion anchor. ### Minimal secure design **Keep existing API data/provider code unchanged** (`dashboard_v5/read_api.py:63-70`, `:759-812`): its exact route allowlist, strict query parsing, read-only SQLite connection, output bounds, host/origin/fetch-metadata checks and no-store JSON handling are reusable. In `health_dashboard_server.py`: 1. **Separate API credential roles.** - Retain `HEALTH_DASHBOARD_API_TOKEN_FILE` and `load_api_token()` only for non-browser/API-client use if still required. - Add a separately configured, private `HEALTH_DASHBOARD_BROWSER_SESSION_SECRET_FILE`, validated with the same regular-file/owner/`0600`/`O_NOFOLLOW` discipline as `load_api_token()` (`:101-123`). - Do **not** mint an API session merely because someone loads `/health-dashboard-v5`; that would make API access unauthenticated. 2. **Add an explicit same-origin browser-session bootstrap route**, e.g. `POST /api/v1/browser-session`. - Gate it before normal API dispatch: allowed Host, exact POST route, supplied matching `Origin`, `Sec-Fetch-Site` same-origin/none, no CORS. - Authenticate the bootstrap using a separate server-side browser-login mechanism/secret; the existing untracked 6B.1 test proposes `Authorization: Basic browser:` (test `:36-37`, `:67-76`). - On success issue an opaque, random, server-side tracked session ID: - `Set-Cookie: health_api_session=; Path=/api/v1; HttpOnly; SameSite=Strict; Max-Age=` - Add `Secure` when serving HTTPS. Current server intentionally only accepts `http` origins (`origin_matches_request`, `:204-228`), so unconditional `Secure` would break the current HTTP loopback test/runtime. Do not claim HTTPS cookie protection until deployment architecture changes. - Do not place the API Bearer token, session bootstrap secret, or user data inside the cookie. - Bounded in-memory sessions with expiry/revocation are minimally sufficient for the current single-process local server; use a locked map analogous to the existing CSRF/receipt stores (`:90-94`, `:231-281`). Multi-worker deployment would require a shared session store or sticky single worker. 3. **Change normal API auth at `Handler._handle_api()` (`:577-608`).** - Replace the Bearer check at `:582-587` with strict cookie parsing (`SimpleCookie` is already imported at `:16`) and constant-time validation against the opaque server-side session. - Keep `401` with existing no-store JSON error path, but remove `WWW-Authenticate: Bearer` from `_send_api_json()` (`:638-639`) for browser-session API failures. A Bearer challenge encourages the wrong browser flow. - Keep current cross-origin controls at `:589-595`; reject mismatching supplied Origin and cross-site Fetch Metadata. 4. **Make CSP route-specific without weakening V4.** - At `_handle()` `:518-545`, pass an explicit `api_enabled=(path == V5_ROUTE)` (or similarly named boolean) to `_send_bytes()`. - At `_send_bytes()` `:682-689`, build CSP with: - V4 (`/health-dashboard`): unchanged `connect-src 'none'`. - V5 (`/health-dashboard-v5`): only `connect-src 'self'`. - Do not globally change the policy and do not add external origins/CORS. - The current CSRF cookie from `:690-694` is independent of the new API session; retain it for the existing POST capture route. 5. **Add a small V5-only relative-fetch client.** - Best minimal attachment point: `dashboard_v5/render.py:96` to include a new local V5 API JS asset, plus `ASSET_ROUTES` in `health_dashboard_server.py:59-65`. - Client should use relative `/api/v1/...` requests with `credentials: 'same-origin'`; it must set **no** `Authorization` header and must not read/write token/session values to DOM, URLs, storage, or logs. - Alternatively fold this into `dashboard-v5.js`; the file is V5-only in the current renderer. A separate small API module is easier to audit. ### Tests to add/change Primary test file: `tests/test_dashboard_v5_sprint6b_api.py`. - Replace/retire the Bearer HTTP assertions in `test_http_api_requires_private_bearer_token_and_never_caches()` (`:562-736`) with: - unauthenticated API `401`, no-store; - valid bootstrap returns `204`, opaque cookie with `HttpOnly`, `SameSite=Strict`, `Path=/api/v1`, short expiry; - cookie-authenticated same-origin GET `200`; - wrong/expired/revoked/malformed session `401`; - API requests with Bearer only must **not** authenticate the browser-session route; - cross-origin Origin and `Sec-Fetch-Site: cross-site` remain `403`; - every bootstrap/API success and failure has `Cache-Control: no-store`, no CORS headers, and no Bearer challenge. - Add direct tests for V4/V5 CSP: - V4 `/health-dashboard`: contains `connect-src 'none'`; - V5 `/health-dashboard-v5`: contains exactly/at least `connect-src 'self'`, no external `connect-src`; - neither uses `unsafe-inline`. - Add source/browser-contract assertions: - V5 response body, V5 JS, request URLs, storage and console contain no Bearer/session secret; - V5 same-origin `fetch` succeeds with cookies; - a foreign page/origin cannot read/call the API. - Preserve current negative route/method/absolute-form tests at `test_dashboard_v5_sprint6b_api.py:647-688`; they are valuable and already validate no-store API error handling. ### Existing 6B.1 in-progress test evidence A concurrent untracked test appeared during the audit: - `tests/test_dashboard_v5_sprint6b1_integration.py` - It already specifies the desired V5 CSP, `HttpOnly; SameSite=Strict; Path=/api/v1` session cookie, no Bearer in V5 HTML, same-origin cookie API use, and foreign-origin rejection (`:40-92`). - It currently fails exactly at the expected missing work: - V5 still returns `connect-src 'none'` (assertion at `:60`); - weekly aggregation contract is also not yet implemented (`KeyError: aggregation_rule` at `:107`; outside this auth/CSP audit). - Executed baseline: `PYTHONPATH=/home/agent/.hermes/repos/HealthManager pytest -q -p no:cacheprovider tests/test_dashboard_v5_sprint6b_api.py` → **14 passed**. - Executed 6B.1 integration test → **1 passed, 2 failed** as above. ### Audit integrity / issues - Initial reviewed tracked tree was clean at `HEAD 9d611ea`; no edits by this audit. - Final tree contains concurrent untracked files, not created by me: - `scripts/health/dashboard_v5/apple_identifier_report.py` - `tests/test_dashboard_v5_sprint6b1_integration.py` - I did not inspect production DBs, reports, environment files, or secrets.