Skip to content

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 ship static (it is unauthenticated; the app refuses to start in it without KNEO_DASH_DEV_MODE, which you must not set).
  • [ ] KNEO_DASH_SESSION_SECRET set to a strong random value (openssl rand -base64 32), not change-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). Set KNEO_DASH_OIDC_BOOTSTRAP_ADMIN for 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_ORIGINS empty.
  • [ ] Non-root container (the image already runs as uid 10001); pin the image by digest.
  • [ ] Session lifetimes reviewed (SESSION_TTL_SECONDS / SESSION_IDLE_SECONDS).
  • [ ] /metrics left disabled unless needed; if enabled, gate it with a ≥32-char KNEO_DASH_METRICS_TOKEN and 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_CLAIMKNEO_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_SECRET invalidates 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:

  1. SameSite=Lax cookie — blunts cross-site cookie-driven requests.
  2. Explicit Origin/Referer same-origin check on every mutating /api/* request (POST/PUT/PATCH/DELETE): a request whose Origin host does not match the request Host is refused 403. A request with no Origin/Referer (a non-browser client) is allowed — browsers always attach Origin to 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;
  • Host must be the public host the browser uses — the same-origin guard and the OIDC Origin all key off it.
  • X-Forwarded-Proto: https so the BFF knows it is fronted by TLS. The session cookie is always Secure regardless, and the OIDC callback is the explicitly-configured public KNEO_DASH_OIDC_REDIRECT_URL — so the callback does not rely on scheme sniffing.
  • Do not expose :8090 directly; only the proxy should reach it.

The recipe implements the two controls the BFF delegates to the proxy (0.8.0):

  • HSTSStrict-Transport-Security: max-age=31536000 on the 443 block (always, so error responses carry it too). includeSubDomains ships as a commented opt-in — enable it only when every descendant hostname is HTTPS; no preload before GA.
  • Login rate-limiting/api/login + /api/callback are throttled per client IP (10 req/min, burst 5). An over-limit request gets a 429 in the standard error envelope (code: "rate_limited", retry_after: 6, a proxy-minted request_id correlated across body, X-Request-Id header, 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/me401; 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_ORIGINS exists only for the dev Vite proxy.
  • The dashboard is not a secrets manager — the platform API key lives in the kneo-client profile store, never in the browser.

See also