Security hardening & auth operations¶
Hardening the Kneo Agent Dashboard for a shared/production deployment, and what its defences actually do. The dashboard's security model is set out in ADR-009; this page is the operator-facing distillation.
The platform credential is a shared per-environment service account, so the dashboard — not the platform — is the real per-operator gate. That is why authentication + server-side RBAC on the BFF matter: they are the boundary between an operator and a privileged action.
Pre-launch checklist¶
- [ ]
KNEO_DASH_AUTH_MODE=oidc— never shipstatic(it is unauthenticated; the app refuses to start in it withoutKNEO_DASH_DEV_MODE, which you must not set). - [ ]
KNEO_DASH_SESSION_SECRETset to a strong random value (openssl rand -base64 32), notchange-me(the app fail-fasts on the placeholder). - [ ] OIDC role map configured (
KNEO_DASH_OIDC_ROLE_MAP) — an identity with no mapped role is denied (default-deny). SetKNEO_DASH_OIDC_BOOTSTRAP_ADMINfor recovery. - [ ] Served over TLS behind a reverse proxy that forwards
Host+X-Forwarded-Proto. - [ ] Same-origin — SPA and BFF on one origin; leave
KNEO_DASH_CORS_ORIGINSempty. - [ ] Non-root container (the image already runs as uid
10001); pin the image by digest. - [ ] Session lifetimes reviewed (
SESSION_TTL_SECONDS/SESSION_IDLE_SECONDS). - [ ]
/metricsleft disabled unless needed; if enabled, gate it with a ≥32-charKNEO_DASH_METRICS_TOKENand don't expose it publicly.
Authentication: OIDC vs static/dev¶
static mode is a single, unauthenticated operator for local development only — the app
refuses to start in it unless KNEO_DASH_DEV_MODE=1, so a real deploy cannot silently be
unauthenticated-admin. Production uses oidc: OAuth 2.0 Authorization Code + PKCE
(S256), with state validated on callback (CSRF on the login round-trip). Roles come from
the identity's claims (KNEO_DASH_OIDC_ROLE_CLAIM → KNEO_DASH_OIDC_ROLE_MAP), enforced
server-side on every privileged action. See the environment reference.
Sessions¶
The session cookie is an opaque, signed session id — no session data lives in the cookie.
It is set HttpOnly + Secure + SameSite=Lax + Path=/, host-only (no Domain).
- Server-side store. Session state lives in the dashboard DB, so revocation and rotation are first-class — deleting/rotating the row invalidates the session immediately.
- Rotation on login. The session id rotates on login (session-fixation defence).
- Expiry. Both an absolute lifetime (
KNEO_DASH_SESSION_TTL_SECONDS, default 12h) and an idle timeout (KNEO_DASH_SESSION_IDLE_SECONDS, default 30m). Expired rows are purged on a background loop so the table stays bounded. - Secret rotation. Rotating
KNEO_DASH_SESSION_SECRETinvalidates all existing session signatures (operators re-login) — the mechanism for a suspected-compromise reset.
CSRF & the same-origin model¶
Same-origin SPA + BFF is mandatory (ADR-009 §4). Defence-in-depth against CSRF:
SameSite=Laxcookie — blunts cross-site cookie-driven requests.- Explicit
Origin/Referersame-origin check on every mutating/api/*request (POST/PUT/PATCH/DELETE): a request whoseOriginhost does not match the requestHostis refused403. A request with noOrigin/Referer(a non-browser client) is allowed — browsers always attachOriginto state-changing requests, so only a present, mismatched origin is a cross-site attempt.
Because the check compares Origin to the Host header, your reverse proxy must forward
Host unchanged (see below) — a proxy that rewrites Host will break the guard.
HTTP response headers (0.8.0)¶
Every response the BFF emits — the SPA shell, hashed assets, and /api/* (SSE included) —
carries a browser-security header set, enforced (not report-only) and asserted by tests:
| Header | Value |
|---|---|
Content-Security-Policy |
the committed policy below |
X-Frame-Options |
DENY (legacy clickjacking fallback; CSP frame-ancestors is the modern control) |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
no-referrer |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=(), usb=() |
Cache-Control |
no-store on /api/* · public, max-age=31536000, immutable on hashed /assets/* · no-cache on the SPA shell |
The committed CSP policy (the exact emitted value — a test asserts this doc stays
identical to the code fixture in kneo_dash/security_headers.py):
default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'
Why style-src carries 'unsafe-inline': the React components set style={{…}}
attributes, which CSP treats as inline style; script injection — the attack CSP chiefly
guards — remains blocked by script-src 'self'. Everything else is deny-by-default. The
E2E fleet drives the production-built SPA (scripts, styles, assets, /api calls, and the
SSE trace tail) under this enforced policy, so a directive that broke the app would redden CI.
Delegated to the reverse proxy (see below): Strict-Transport-Security (HSTS) and
login rate-limiting — TLS-terminating concerns the BFF can't own.
Reverse proxy & TLS¶
Terminate TLS at a reverse proxy and forward to the BFF on :8090. The example
nginx.conf sets the
headers that matter:
proxy_set_header Host $host; # load-bearing: the same-origin guard compares Origin↔Host
proxy_set_header X-Forwarded-Proto $scheme; # tells the BFF the external scheme is https
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Hostmust be the public host the browser uses — the same-origin guard and the OIDCOriginall key off it.X-Forwarded-Proto: httpsso the BFF knows it is fronted by TLS. The session cookie is alwaysSecureregardless, and the OIDC callback is the explicitly-configured publicKNEO_DASH_OIDC_REDIRECT_URL— so the callback does not rely on scheme sniffing.- Do not expose
:8090directly; only the proxy should reach it.
The recipe implements the two controls the BFF delegates to the proxy (0.8.0):
- HSTS —
Strict-Transport-Security: max-age=31536000on the 443 block (always, so error responses carry it too).includeSubDomainsships as a commented opt-in — enable it only when every descendant hostname is HTTPS; nopreloadbefore GA. - Login rate-limiting —
/api/login+/api/callbackare throttled per client IP (10 req/min, burst 5). An over-limit request gets a429in the standard error envelope (code: "rate_limited",retry_after: 6, a proxy-mintedrequest_idcorrelated across body,X-Request-Idheader, and the proxy access log) — see the API contract. BFF-native throttling is deliberately deferred; the proxy is the enforcement point in the supported topology. CI asserts both the rendered config and the live behavior every cut.
What an attack attempt does¶
| Attempt | Result |
|---|---|
| Expired / revoked session | /api/me → 401; the SPA redirects to login (/api/login). No privileged action executes on a stale session. |
Cross-origin mutation (a state-changing /api/* from another origin) |
403 — the same-origin guard rejects a present, mismatched Origin, on top of the SameSite=Lax cookie. |
| Capability escalation (invoking an action whose capability the effective Access map does not grant the operator) | 403 — the BFF enforces the capability server-side on every privileged action, regardless of what the SPA renders. |
| Unmapped OIDC identity (authenticates, but no role claim maps) | denied — default-deny; no dashboard role is granted. |
settings.write is a meta-capability — grant it sparingly
Capabilities are configurable: an operator with settings.write can edit the
Access map (Settings › Access), which means they can grant any capability to any
role — including granting their own role every capability (audit read, launch, policy
write, …). Treat settings.write as Access-map administration, not an ordinary
setting: give it only to trusted admins, and watch changes to the Access map (a
settings.write change gets a best-effort audit append — best-effort, so alert on
kneo_dash_audit_write_failures_total). Authorization is by capability (the role
resolves through the effective Access map); role names like "admin" below are the
built-in default, reassignable — not fixed role checks.
Container hardening¶
- Non-root — the image runs as uid
10001; keep it non-root under your orchestrator's security context, read-only root filesystem where possible (the state volume is the only writable path with the default SQLite store). - Pin by digest and scan the image; the base is a slim Python runtime.
- Least privilege for the platform API key — it is a per-environment service account; scope it to what that environment needs.
Non-goals (explicit)¶
- Credentialed cross-origin hosting (SPA on a different origin than the BFF) is out of
scope — it would need
SameSite=None; Secure+ a full CSRF-token scheme.KNEO_DASH_CORS_ORIGINSexists only for the dev Vite proxy. - The dashboard is not a secrets manager — the platform API key lives in the
kneo-clientprofile store, never in the browser.
See also¶
- Deployment guide — how to run it (container /
pip install, probes, non-root). - Environment-variable reference — auth/OIDC/session settings.
- Connecting — the
kneo-clientprofile and environments.