Visual CMS
An authenticated, browser-based visual content management system for nontechnical website owners. Users do not need to understand Kubernetes, HTML, CSS, JavaScript, templates, YAML, Git, container images or deployment pipelines.
Purpose and UX
Prioritise content editing over technical configuration. The core flow is: open the CMS, sign in, select a page, click content to edit it, add images or components, preview changes, save a draft, publish. Advanced design controls should not obstruct ordinary editing tasks.
Visual editors: Tiptap edits Markdown and GrapesJS edits HTML/templates, with DOMPurify sanitisation and safe defaults (scripts/unsafe attributes disabled, no outbound Font Awesome CDN/telemetry) so it runs cleanly under the CMS Content-Security-Policy. See editor internals.
Features
Implemented capabilities include rich/source Markdown, visual HTML layout, inline text and drag-and-drop blocks, page/menu management, media, theme layouts/shared parts, responsive preview, drafts, publication, site settings, site-level SEO fields, sitemap/robots generation, WordPress import, raw downloads, repeated listings and local block/renderer/theme packages. The current Page API has no per-page SEO-description field.
Role allowlists gate CMS access and publish/rollback; they do not provide per-page/subtree permissions. Scheduled publishing, collaborative editing, dynamic contact forms and a hosted marketplace remain proposals. See implementation status and API.
Storage
The CMS uses ordinary filesystem storage on the private volume; no database is required for the MVP. Layout per Contracts. Editable project state is preserved independently of generated HTML; generated output is never treated as the authoritative source.
Runtime
The CMS backend is Go (CGO_ENABLED=0, scratch runtime). Third-party
dependencies implement OAuth2/OIDC, Markdown, HTML/CSS sanitization, WordPress
conversion/MySQL and bounded WASI rendering. The complete inventory is
cms/go.mod; rationale and source boundaries are in the
repository map. Because the CMS
talks to the identity provider and the Kubernetes API over HTTPS, its image
carries the system CA bundle. Compiled browser assets are served by the Go
backend at /; the editor needs no separate production runtime.
Authentication (built-in OAuth2/OIDC client)
The CMS is the authentication boundary: it embeds an OAuth2/OIDC client that
guards the whole :8080 listener, so the CMS Ingress only terminates TLS and
carries no auth annotations. Configuration comes from the Secret referenced by
spec.cms.authentication.secretRef (see
Contracts); the operator mounts it
read-only at /etc/viking/auth and sets VIKING_AUTH_SECRET_DIR and
VIKING_COOKIE_SECURE=true. If the configuration is missing or invalid the
manager is not ready: admin /readyz reports 503 and the protected surface
refuses to serve — the CMS never runs unauthenticated.
Public endpoints:
GET /oauth2/start— Authorization Code flow start (single-use state cookie + nonce + PKCE for public clients).GET /oauth2/callback— code exchange, ID-token validation (or userinfo fallback), RBAC check, session-cookie issue.POST /oauth2/logout— CSRF-protected logout (session cookie +X-CSRF-Token- same-origin
Origin/Referer); clears the session and returns200 {"logoutURL": "<idp end-session URL>"}(or"/"). AGETrequest to/oauth2/logoutreturns405and performs no side effect. The editor's Log out button performs the POST and then navigates tologoutURL.
- same-origin
Protected surface:
GET /api/me—{email, name, roles, issuer, subject}for the signed-in user (401unauthenticated). The editor loads it on startup: on200it shows the identity chip (email/name) and a Log out button; on401it redirects to/oauth2/startbefore editing begins. A mid-session401keeps the editor open and blocks writes. Carry on working resumes local editing; Log in again checkpoints the draft and navigates to login in the same window. Recovery verifies the same issuer/subject and refreshes CSRF. See editor recovery.- Everything else (SPA and the rest of
/api/*) runs behind the auth middleware: session cookie or Bearer token, RBAC viaallowedRoles.
Bearer tokens (machine clients): Authorization: Bearer is validated as a JWT
via the provider JWKS (issuer always checked; audience = audience or client
id) or, when that fails and introspectionURL is set, via RFC 7662
introspection.
Publishing gate: when publishRoles is set in the auth Secret, POST /api/publish and POST /api/publish/rollback require the user to hold at least
one of those roles (403 otherwise); unset, any allowedRoles user may
publish.
IdP endpoints are HTTPS-only (discoveryURL/authURL/tokenURL/jwksURL/
issuer/userinfoURL/introspectionURL); plain http:// is accepted only for
loopback hosts with the development flag --allow-insecure-auth-http /
VIKING_ALLOW_INSECURE_AUTH_HTTP=true. --cms-hostname / VIKING_CMS_HOSTNAME
(or the cmsHostname secret key) pins the external CMS hostname used to build
OAuth2 redirect URIs. The fixed-CSRF-token mode was removed — CSRF is
always the per-session cookie + header + Origin scheme below.
The session cookie (viking_session) is normally self-contained and encrypted: claims are
AES-256-GCM encrypted (key derived from jwtSecret) and the blob is HMAC-SHA256
signed — opaque to anyone who can read cookies (see
Contracts).
Large identities use an encrypted reference cookie backed by a bounded local cache, so those sessions need login again after restart/eviction.
The short-lived login state store is held in memory, so the CMS runs as a
single replica (the operator deploys replicas: 1). See
Publisher.
CSRF protection (anti-CSRF, never authorization)
GET /api/publish/status is the CSRF bootstrap: it sets a per-session cookie
(viking_csrf, Path=/, HttpOnly, SameSite=Strict, plus Secure when
--csrf-secure/VIKING_CSRF_SECURE) and returns a random per-session token in
both the X-CSRF-Token header and the csrfToken JSON body field. Repeated
status GETs reuse the session rather than rotating it.
Every browser-session mutating /api/* request must present:
- the session cookie (naming a known, unexpired session),
- an
X-CSRF-Tokenheader matching that session's stored token (compared constant-time), and - a same-origin
Origin(preferred) orRefererheader.
Verified explicit Bearer identities are exempt from the API cookie/token
requirement, but supplied Origin/Referer headers are still checked. Missing
origin headers are accepted for non-browser clients. Any mismatch returns 403.
This is anti-CSRF only, never authorization: the
built-in auth middleware (session or Bearer) authenticates the user before these
handlers run. CSRF sessions are held in memory, so the CMS must run as a
single replica (see Publisher). The CSRF check
runs after authentication, so the CSRF bootstrap (/api/publish/status) is
reached by an already-authenticated session.
Security
The CMS validates all file uploads, restricts filesystem access, prevents path traversal, applies CSRF protections, validates publication requests, avoids exposing private project files, restricts access to publishing operations, uses secure session handling and avoids arbitrary command execution. Authentication itself is enforced by the built-in OAuth2/OIDC client, which is a boundary but not a substitute for treating browser input as untrusted.
Concrete hardening applied by the CMS:
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrerandX-Frame-Options: DENYon all responses (API, SPA and admin).- SPA responses carry 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', with an additional SHA-256 source hash inscript-srcfor the fixed gallery runtime used by opaque previews. Arbitrary inline scripts andunsafe-evalremain forbidden;'unsafe-inline'instyle-srcis required by GrapesJS,img-src data:supports the sandboxed preview, andframe-ancestors 'none'prevents the CMS being framed. - Published page HTML and CSS are sanitised at publish time with bluemonday and a CSS scrubber so the unauthenticated public origin cannot execute stored scripts (see Security and Publisher).
- Normal SVG media uploads are rejected (
415): servingimage/svg+xmlon the same origin would execute scripts embedded in the SVG. - Normal executable media uploads are rejected:
.js,.mjsand.cssextensions andtext/javascript/application/javascript/text/csscontent types return415even when the filename carries an image extension; non-image uploads are served withContent-Disposition: attachment. Explicit raw uploads accept unchanged arbitrary bytes only as octet-stream attachments, not executable page content; see extensions. - Uploads are capped (excess returns
413) and validated against a MIME/extension allowlist; asset content is served withCache-Control: private, max-age=300; path traversal is impossible (ids/paths validated before any filesystem access); JSON writes are temp-file-plus-rename.
See Security and Authentication.
Status write (websites/status)
When VIKING_WEBSITE_NAMESPACE/VIKING_WEBSITE_NAME (or --website-namespace/
--website-name) are set, the publisher best-effort PATCHes the active release
onto the Website status subresource after each activation (publish or
rollback):
PATCH /apis/cms.tlspu.com/v1alpha1/namespaces/{ns}/websites/{name}/status
Content-Type: application/merge-patch+json
Authorization: Bearer <service-account token>
{"status":{"currentRelease":"000001"}}
This is asynchronous and does not fail a publish. A single worker re-reads
the active pointer at startup, on activation and every five seconds, retrying
failures so stale notifications cannot overwrite newer releases. The service
account token is re-read for every call (projected tokens rotate). Synchronization
is disabled without a Website identity; configured missing tokens/API errors
are logged and retried. The CMS pod
automounts its ServiceAccount token to perform this write; the generated
<site>-cms-status Role limits it to get/patch/update on websites/status
only. The legacy annotation cms.tlspu.com/current-release is no longer
written.
API
JSON under /api/*; the SPA is served at /. Endpoints per
Contracts.
