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:
- A browser visits the CMS and is redirected to
/oauth2/start, which starts the Authorization Code flow with a short-lived, single-usestate(bound to the browser via a per-flowviking_oauth_state_<state>cookie), anonce, and a PKCEcode_verifierwhen the client is public. - 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), enforcesallowedRoles, and issues the session cookie. GET /api/mereturns{email, name, roles, issuer, subject}. Logout is a CSRF-protectedPOST /oauth2/logout(session cookie +X-CSRF-Token+ same-originOrigin/Referer): it clears the session and returns200 {"logoutURL": "<idp end-session URL>"}(or"/"); aGETrequest to/oauth2/logoutreturns405with 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).
