Content Management that can withstand the plundering hoards

Security Model

Security model for Viking CMS.

Isolation

Each website has an independent frontend Deployment, Service, rolling updates and mounted content, reducing cross-website exposure. Generated site containers currently have no explicit CPU/memory resource limits or requests; provide those through platform policy where required. Kubernetes namespaces group resources but do not themselves enforce network isolation.

Public serving layer

The serving layer has no CMS credentials, private content access, database access, Kubernetes API access, shell, writable application filesystem, or unnecessary network egress. The published filesystem contains only public data. Every frontend response carries X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.

CMS layer

The CMS is authenticated and isolated from public serving traffic. It requires write access to the private RWX PVC and the published RWX PVC. Generated NetworkPolicies are temporarily disabled while network requirements are established. The publisher validates generated output and uploaded content.

Every CMS response (API, SPA and admin) carries X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and X-Frame-Options: DENY (the latter a legacy fallback for the CSP frame-ancestors 'none').

Authentication (the CMS is the boundary)

The CMS backend itself is the authentication boundary — there is no ingress authentication adapter. The operator mounts the referenced authentication Secret read-only into the CMS pod at /etc/viking/auth and the CMS runs a built-in OAuth2/OIDC client that guards the whole :8080 listener. The CMS Ingress only terminates TLS and carries no auth annotations; it routes everything to the CMS Service, which handles authentication itself.

The flow:

  1. A browser visits the CMS and is redirected to /oauth2/start, which starts the Authorization Code flow with a short-lived, single-use state (bound to the browser via a per-flow viking_oauth_state_<state> cookie), a nonce, and a PKCE code_verifier when the client is public.
  2. The IdP redirects back to /oauth2/callback; the CMS exchanges the code, validates the ID token (issuer, audience, expiry, nonce — or falls back to userinfo when no ID token is returned), enforces allowedRoles, and issues the session cookie.
  3. GET /api/me returns {email, name, roles, issuer, subject}. Logout is a CSRF-protected POST /oauth2/logout (session cookie + X-CSRF-Token + same-origin Origin/Referer): it clears the session and returns 200 {"logoutURL": "<idp end-session URL>"} (or "/"); a GET request to /oauth2/logout returns 405 with no side effect.

Session cookie (viking_session): normally self-contained and encrypted — base64url(nonce‖AES-256-GCM-encrypted-claims).base64url(HMAC-SHA256(jwtSecret, blob)). The claims are AES-256-GCM encrypted (key derived from jwtSecret) and the blob is HMAC-SHA256 signed, so the cookie is opaque to anyone who can read it. HttpOnly, SameSite=Lax, Secure by default (the operator sets VIKING_COOKIE_SECURE=true; the CMS only serves behind TLS). TTL defaults to 1h (sessionTTL). Large identities use an encrypted reference cookie and a bounded local session cache; restarting the CMS or evicting a cached identity requires those users to log in again. Roles and logout hints are preserved.

PKCE: public clients (publicClient: "true") always use S256 PKCE with no client secret; confidential clients authenticate with the clientSecret.

RBAC: allowedRoles is a space/comma allowlist; when set, the user must hold at least one role from the configured rolesClaim. An empty allowedRoles admits any authenticated user. Denial returns 403.

Publishing gate: publishRoles is an optional space/comma allowlist layered on top of allowedRoles; when set, POST /api/publish and POST /api/publish/rollback additionally require the user to hold one of those roles (403 otherwise). Unset, any allowedRoles user may publish.

IdP transport security: all IdP endpoints (discoveryURL, authURL, tokenURL, jwksURL, issuer, userinfoURL, introspectionURL) must be https://. Plain http:// is accepted only for loopback hosts and only with the development flag --allow-insecure-auth-http / VIKING_ALLOW_INSECURE_AUTH_HTTP=true; otherwise a MITM could swap the JWKS (→ token forgery) or inject roles through an http introspection endpoint.

Bearer tokens (machine clients): an Authorization: Bearer header is validated either as a JWT against the provider JWKS (issuer always checked; audience is audience when set, else the client id) or, when that fails and introspectionURL is configured, via RFC 7662 introspection (the token must come back active with matching iss and aud; any present exp and nbf are enforced). Roles extracted from the token are subject to the same RBAC.

Behaviour when unauthenticated: /api/* returns 401 JSON; a browser-like navigation to a non-/api path is redirected (302) to /oauth2/start. When the auth configuration is missing or invalid the CMS is not ready: admin /readyz reports 503 and the protected surface refuses to serve (503) — the CMS never runs unauthenticated.

The admin listener (:8081) — /healthz, /readyz, /metrics — stays unauthenticated so health checks and scraping work without credentials.

TLS: the ingress terminates TLS only. Session cookies are Secure, the OAuth2 redirect URI is the CMS hostname over HTTPS, and IdP endpoints are contacted over HTTPS only (see the transport-security note above). The ingress certificate handling is unchanged.

Outbound HTTPS dependency: the CMS makes outbound HTTPS calls to the IdP (discovery, JWKS, token, userinfo, introspection) and, when configured, to the Kubernetes API for the websites/status write. The scratch runtime therefore carries the system CA bundle in the image. Generated NetworkPolicies are currently disabled; no operator-generated egress restriction blocks these calls.

CSRF (anti-CSRF, never authorization)

GET /api/publish/status issues a per-session cookie (viking_csrf, HttpOnly, SameSite=Strict, Secure behind TLS) and a random per-session token returned in the X-CSRF-Token header and csrfToken body. Browser-session mutating requests must present the cookie, a matching token, and a same-origin Origin/Referer; any mismatch returns 403. This prevents cross-site request forgery but is never an authorization boundary — the auth middleware (the built-in OAuth2/OIDC client) authenticates the user before the CSRF check runs. CSRF sessions and the login state store are in memory, so the CMS must run as a single replica.

For /api/*, a successfully validated explicit Bearer token authenticates a machine request without a CSRF cookie/token. The auth layer records the method on the trusted identity; token claims and header presence cannot assert it. Invalid Bearer credentials still fail authentication, and role gates still apply. Supplied Origin/Referer headers must remain same-origin. OAuth logout retains its independent browser CSRF protection.

Sanitisation of published output

Published HTML is sanitised at publish time with github.com/microcosm-cc/bluemonday (BSD-3-Clause, Apache-compatible — a deliberate dependency described in the source map) plus a CSS scrubber, so the unauthenticated public origin cannot execute stored scripts or event handlers. The CMS SPA itself runs under a Content-Security-Policy (default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'), plus a SHA-256 source hash allowing only the fixed gallery runtime in opaque previews. Arbitrary inline scripts and unsafe-eval remain forbidden, verified against the real browser. Public gallery pages embed that same CMS-owned runtime after sanitization; ZIPs and page content cannot supply scripts. Normal SVG media uploads are rejected (415) because serving image/svg+xml on the same origin could execute scripts embedded in the SVG. Explicit raw uploads permit arbitrary bytes only as octet-stream attachments; publishing does not execute those bytes. WASI renderer packages are bounded CMS-side execution with no filesystem/network/process access, not scripts served to visitors.

Container security

Required settings where supported:

securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  runAsGroup: 65532
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL
  seccompProfile:
    type: RuntimeDefault

The auth Secret volume is mounted read-only with defaultMode 0444 so the non-root CMS user (65532) can read the mounted files; the pod runs a single app container and no other workload shares it.

The CMS pod runs a volume-permissions init container that chowns the private and published volume roots to UID/GID 65532 before the CMS starts (the kubelet does not chown CSI volumes, so drivers that ignore fsGroup would otherwise leave the volumes root-owned). It uses the same CMS image with the prepare-volumes subcommand and its own hardened context: root only for the chown step, allowPrivilegeEscalation: false, read-only root filesystem, seccomp: RuntimeDefault, and all capabilities dropped except CHOWN. The CMS app container keeps the full non-root runAsNonRoot/runAsUser: 65532/runAsGroup: 65532 context.

ServiceAccount token automounting is disabled for the webserver (no API access). The CMS automounts its token, but only to write status.currentRelease through the websites/status subresource: the generated <site>-cms-status Role limits the token to get/patch/update on that subresource for its own Website name and nothing else, so the mounted token cannot change the Website spec or any other resource. The operator itself keeps full API access (its own least-privilege ClusterRole).

Supply-chain security

CI includes SAST, dependency scanning and secret detection. The container-scanning include is commented out in the checked-in pipeline. SBOM generation and image provenance are planned follow-ups. The shared tlspu/ci-utils CI template has no ref pin in this checkout; pinning it is a supply-chain follow-up. In production CI, builder images in the Dockerfiles should be digest-pinned. Runtime images must not contain build tools (all components are multi-stage to scratch).