A security hardening release covering authentication, forward auth, stored secrets, instance sync, mTLS and the WAF (#296). Several changes need action from operators. Read the upgrade notes below before pulling the new images. The README section Upgrade Notes → Upgrading from v1.12.0 or earlier covers every item in full.
Upgrade notes
Pull and recreate the containers with docker compose pull && docker compose up -d. docker compose restart does not re-read .env.
Check before upgrading
-
Example secrets are refused. In production the web container refuses to start when
SESSION_SECRETis a shipped placeholder (including the old.env.examplevalueyour-secure-session-secret-here-min-32-chars) orADMIN_PASSWORDis an example password from an earlier README or.env.example("ADMIN_PASSWORD is an example value from the documentation; choose your own password"). Generate a new secret withopenssl rand -base64 32. Stored secrets that were encrypted under the placeholder are re-encrypted with the new secret on the next start, so you don't have to re-enter anything. For any other rotation, put the old value inSESSION_SECRET_PREVIOUS(see Rotating SESSION_SECRET in the README)..env.examplenow leavesSESSION_SECRET,ADMIN_PASSWORDandCLICKHOUSE_PASSWORDempty. -
Using your own compose file? Pass the new variables through. The stock
docker-compose.ymlpassesSESSION_SECRET_PREVIOUS,FORWARD_AUTH_ALLOWED_PORTS,TRUSTED_CLIENT_IP_HEADERandINSTANCE_SYNC_TIMEOUT_MSto the web container. The web container only gets the variables listed in thewebservice'senvironment, so a value that exists only in.envhas no effect. If you run a custom or modified compose file, add these variables there. This also applies to existing variables the stock file doesn't list (e.g.INSTANCE_MODE: ${INSTANCE_MODE:-}). Give numeric variables their documented default rather than an empty value, because an empty value is read as0(e.g.LOGIN_MAX_ATTEMPTS: ${LOGIN_MAX_ATTEMPTS:-5}). -
Forward auth on a non-standard port. If browsers reach forward-auth protected sites on a port other than 80/443 (e.g. Caddy published as
8443:443, or NAT), setFORWARD_AUTH_ALLOWED_PORTS=8443(comma-separated for several) and recreate the web container. Without it, existing forward-auth sessions stop validating, the portal shows "This site is served on port 8443, which is not allowed for forward authentication…" and the web container logs[forward-auth] Rejected host:8443 … FORWARD_AUTH_ALLOWED_PORTS. -
Instance sync: upgrade slaves before the master, or at the same time. An upgraded master seals certificate private keys and DNS provider credentials to each slave's own key (see Instance sync under Highlights). A slave still on v1.12.0 or earlier gets the old payload and needs the master's
SESSION_SECRETas its own. A master still on v1.12.0 or earlier sends DNS provider credentials encrypted with its ownSESSION_SECRET, so every slave needs that secret asSESSION_SECRETor inSESSION_SECRET_PREVIOUS. Otherwise applying the synced config fails. Keep the shared secret until every instance runs v1.13.0. After that, each instance can have its own secret. -
Instance sync no longer follows redirects. Point each slave URL (in the UI or in
INSTANCE_SLAVES) at the final URL. A slave that redirects now fails with "Sync key request failed with HTTP 302" (or 301/308). Each sync request is limited toINSTANCE_SYNC_TIMEOUT_MS(default 60 s; reported as "Sync timed out"), so raise it for slaves that take longer to apply a config. Slave URLs with credentials, a query string or a fragment are rejected, and suchINSTANCE_SLAVESentries are skipped withSkipping INSTANCE_SLAVES entry <index>: <reason>. -
Proxies in front of a slave must pass
GETas well asPOSTon/api/instances/sync, including theAuthorizationheader and the query string, and must not cache theGETreply. Before every sync, the master fetches the slave's sync key with this request. A405makes an upgraded slave look like an older release. Any other refusal fails the sync with "Sync key request failed with HTTP ". -
Sync key pinning starts on the first sync. After the upgrade, the master pins each slave's sync key the first time it sees one. From then on:
- A slave that comes back with a different key (e.g. reinstalled with a new
SESSION_SECRET) fails with "Slave sync key changed; verify the slave, then pin its new key or reset its key pin" until you pin its new key on the master. - When you rotate a slave's
SESSION_SECRET, keep the old value in the slave'sSESSION_SECRET_PREVIOUSuntil the master has synced to it once (click Sync now on the master). The master then re-pins the new key automatically. For slaves pinned withsyncPublicKey/syncKeyIdinINSTANCE_SLAVES, update the entry instead. - After downgrading a slave to v1.12.0 or earlier, reset its key pin. Otherwise its syncs fail with "Sync key request failed with HTTP 405".
- To leave no trust-on-first-use window, pin each slave's key before the first sync (see New configuration).
- A slave that comes back with a different key (e.g. reinstalled with a new
-
CA private keys stay on the master. CA private keys are now encrypted at rest with
SESSION_SECRETand are no longer synced. The first sync removes the copies that older versions stored on slaves. Slaves still validate client certificates, but a slave promoted to master cannot issue certificates from the existing CAs. Back up the master's database together with itsSESSION_SECRET. -
WAF custom directives. Some lines are no longer sent to Caddy. Stored rules are kept, but they are left out of the generated config, and the web container logs
[waf] <source>: N custom directive line(s) are not sent to Caddy and have no effect: …. The dropped lines are:- rules using file-reading or exec operators (
@pmFromFile/@pmf,@ipMatchFromFile/@ipMatchF,@inspectFile,@validateSchema). The data-file operators still work with the embedded@owasp_crs/*.datafiles when the CRS is loaded. - the
setenvaction, andctl:ruleEnginein any spacing or quoting. - lines Coraza cannot parse, and directives continued over several lines with a trailing
\. - the rest of a chain when one of its lines is dropped.
- rules that reuse an earlier rule's
id:.
Check the log after upgrading and rewrite any affected rules.
- rules using file-reading or exec operators (
-
Host placeholders are sent literally.
{env.*},{system.*}and{file.*}are no longer expanded in default responses, error pages, path-block bodies and redirect rule targets. Request placeholders such as{http.request.uri}and{http.request.host}still expand. For example, rewritehttps://{env.PRIMARY_DOMAIN}{http.request.uri}with a literal host. -
Database file permissions. On startup, the SQLite database and its
-journal/-wal/-shmfiles lose their world permission bits. Owner and group bits are unchanged. A backup job that reads the files as an unrelated user needs to run as the owner or a member of the files' group.
Behaviour changes
- Admin credentials from the environment are applied only when they change.
ADMIN_USERNAME/ADMIN_PASSWORDare applied when the admin is created and whenever they change, instead of on every start. A password changed in the UI now survives restarts. On the first start after upgrading, a stored admin password that differs fromADMIN_PASSWORD(and is notadminor a documented example) is kept, and a warning is logged. To force the env password, changeADMIN_PASSWORDagain and recreate the web container. A changedADMIN_USERNAMEis still applied on that first start. The same steps recover a lost admin password: they reset the primary admin's password, restore its admin role, re-activate it, and sign out its sessions. - Password policy everywhere. Passwords must be 12–256 characters with upper- and lowercase letters, a digit and a special character. This now also applies to admin-created users (dashboard and
POST /api/v1/users), password changes, and Better Auth self-registration (AUTH_ALLOW_SELF_REGISTRATION=true) and reset. Scripts that create users with weaker passwords get400. - Password changes sign out other sessions. Changing or setting a password signs out the user's other dashboard sessions and all of their forward-auth sessions. API tokens are kept, and you can revoke them under Profile → API Tokens.
- Unused Better Auth self-service endpoints are disabled:
/api/auth/update-user,/change-password,/change-email,/delete-user,/unlink-account,/update-session,/verify-passwordand/is-username-available. Use the Profile page or/api/v1/instead. WithAUTH_ALLOW_OAUTH_REGISTRATION=false, an OAuth sign-in can no longer create an account, even if the client asks for sign-up. - Sign-in usernames. The login page signs in by username only and ignores case. Accounts whose stored username the login page cannot use (e.g. an email containing
+, or a mixed-case username) get a usable one at startup, e.g.alice+cpm@example.com→alice-cpm@example.com. Working usernames are never changed. The Profile page shows the username as Sign-in username, and/api/v1/usersreturns it asusername. Tell users whose username now differs from their email. - Unlinking OAuth requires a working username/password sign-in. Users who set a password on an older version must change it once before the Unlink OAuth Account button appears.
- Forward-auth header stripping. Client-supplied identity headers are removed in every
-/_spelling, and Authentik copy headers are stripped too, on every route that reaches the upstream.Authorization,Proxy-AuthorizationandCookieare never stripped, even when listed in the copy headers (generic forward auth used to strip them), so clients' own credentials reach excluded paths, access-list basic auth and the auth server. A value the auth server returns still replaces them. - Portal login rate limits no longer trust a client-sent
X-Real-IPand now also count failures per account. The client address is the rightmostX-Forwarded-Forentry, so behind a CDN setTRUSTED_CLIENT_IP_HEADER(e.g.cf-connecting-ip). Leave it unset when Caddy is the outermost proxy. - WAF events store credential header values (
Authorization,Cookie,Set-Cookie, API-key and token headers, …) and the credentials that rule messages echo as[redacted]. Events stored before the upgrade are not scrubbed. They expire with the analytics retention.
Highlights
Authentication and passwords
- Setting a password revokes sessions atomically. The password change and the session revocation happen in one transaction. Adding a first password to an OAuth-only account requires a sign-in within the last 10 minutes.
- Reliable password sign-in. Setting a password upserts the Better Auth credential account and assigns a valid, unique sign-in username. The Profile page shows the sign-in username, or explains why password sign-in is unavailable. The create-user form shows password policy errors inline.
- Less sensitive data sent to the browser. The Profile page receives
hasPasswordinstead of the password hash. bcrypt now runs asynchronously on login and password paths.
Admin credentials and secrets
- Env admin credentials are tracked with a bcrypt marker, so they are applied only when they change. Applying a new env password revokes the admin's sessions.
SESSION_SECRET_PREVIOUSsupports secret rotation. It holds decryption-only fallback keys, and the rejected placeholders are tried as fallbacks too. At startup, stored secrets are re-encrypted with the current key (Re-encrypted N stored secret(s) with the current SESSION_SECRET). DNS provider and legacy Cloudflare credentials that older releases stored in plaintext are encrypted as well (Encrypted N DNS provider credential(s) that were stored in plaintext).PUT /api/v1/settings/dns-providernow stores credentials encrypted, as the dashboard does.- CA private keys are encrypted at rest. Existing plaintext keys are encrypted on startup. If a CA's key cannot be decrypted, issuing from it returns "The CA private key cannot be decrypted with the current SESSION_SECRET…". Certificates already issued keep working.
Forward auth
- Checks are bound to the routed proxy host. Verify and callback use the proxy host Caddy actually routed the request through (
X-CPM-Proxy-Host-Idplus a proof), not a host re-derived from the request. Non-standard external ports must be listed inFORWARD_AUTH_ALLOWED_PORTS. - Portal login is hardened. The redirect intent is checked before the credentials. Every failure path costs the same single bcrypt comparison. The portal redirect target is URL-encoded.
- Portal login is rate-limited three ways: per client, per (account, client), and per account over a 1-hour window. IPv6 clients are counted per /64. Attempts still being checked count towards every limit, so extra concurrent attempts get
429. The limits useLOGIN_MAX_ATTEMPTS,LOGIN_WINDOW_MSandLOGIN_BLOCK_MS. The README section Login rate limits has the exact ceiling.
Instance sync: sealing and key pinning
- Transport limits. Besides the redirect, timeout and URL rules in the upgrade notes, the master requires the slave's acknowledgement. Periodic syncs never overlap. Synced settings are written in the same transaction as the synced tables.
- Sealed secrets. Before each sync, the master calls
GET /api/instances/syncon the slave (authenticated with the sync token) to fetch the slave's X25519 public key and a single-use nonce. The slave derives the key from itsSESSION_SECRET. The master seals every certificate private key and DNS provider credential to that key (ephemeral X25519, HKDF-SHA256, AES-256-GCM). Each sealed value is bound to its place, the nonce and the rest of the payload.- Proxies, CDNs or tunnels in front of a slave see these values only as ciphertext. TLS is still required.
- The slave opens everything before writing and stores it under its own
SESSION_SECRET, so master and slaves no longer need to shareSESSION_SECRET. - Slaves on older releases answer the key request with
405and get the legacy payload. The master logs this once per slave.
- Key pinning (trust on first use). Slave keys are pinned by normalized base URL.
- If a slave presents a changed key, the sync stops before anything is sent, unless the slave proves possession of the pinned key. That proof is automatic after a
SESSION_SECRETrotation withSESSION_SECRET_PREVIOUS. INSTANCE_SLAVESentries can pin a key up front withsyncPublicKeyorsyncKeyId. Pinned slaves never receive the legacy payload.- Settings → Instance Sync on the master shows each slave's pinned key id and how it was pinned. The Key pin button pins a verified key (Pin key) or resets the pin (Reset key pin). Orphaned pins are listed under Key pins without a slave.
- A slave shows its own key id and public key under Master Connection.
- Pin changes are audited (
instance_sync_key_pinned,instance_sync_key_rotated,instance_sync_key_unpinned).
- If a slave presents a changed key, the sync stops before anything is sent, unless the slave proves possession of the pinned key. That proof is automatic after a
- Clear slave-side errors. Failures show fixed messages on the slave's Settings page: "Sync payload was sealed for a different key; retry", "Sync payload was sealed for an expired or already used key request; retry" and "Sealed secrets in the sync payload could not be opened".
- Editable instances. Instances can now be edited (name, base URL, token, enabled). A new token keeps the key pin, so use Edit rather than removing and re-adding the instance to change a slave's token. A base URL that points to a different endpoint releases the old URL's pin.
Users
- Deleting a user removes all dependent rows in one transaction: sessions, API tokens, OAuth and credential accounts, pending OAuth links, forward-auth sessions and grants, and group memberships. SQLite foreign keys are not enforced, so this is done explicitly. Proxy hosts, certificates, access lists and other resources they owned or created are kept without an owner. Audit log entries are kept without a user.
- Leftovers from older releases are cleaned up. Startup clears rows left behind by users deleted on older releases (
Cleared rows left by deleted user id(s) <ids>). A new account that reuses a deleted user's id, including the recreated primary admin (id 1), inherits nothing.
WAF and Caddy config
- Custom directives are checked as parsed rules. Lines are judged after parsing, not by text matching. The README's WAF section has the full list of dropped lines. Data-file operators are allowed only with the embedded coreruleset files, and only when the CRS is loaded.
- Stored rules don't block unrelated edits. Saves reject only lines they newly drop. This applies to proxy hosts, the global WAF settings in the dashboard, and
PUT /api/v1/settings/waf. Stored rules that are dropped are logged at config build with their source. The quick templates insert only rules the filter keeps. - mTLS policies are grouped more precisely. TLS client-auth connection policies are grouped by CA set and pinned leaf set. Two hosts that pin different client certificates from the same CA never share a policy.
Other hardening
- Public routes get the full CSP.
/loginand/portalnow receive the full nonce-based CSP and the same security headers as dashboard pages. The login page reloads after a password login so the dashboard gets its own nonce. - Analytics chart labels are sanitized. Labels derived from requests are cleaned before they are rendered.
New configuration
Environment variables (all optional, and passed through by the stock docker-compose.yml):
SESSION_SECRET_PREVIOUS: earlierSESSION_SECRETvalues, comma-separated. Used only to decrypt, and on a slave to prove a rotated sync key.FORWARD_AUTH_ALLOWED_PORTS: non-standard external ports (e.g.8443) on which forward-auth protected sites are served.TRUSTED_CLIENT_IP_HEADER: header holding the real client IP for the portal login and sync endpoint rate limits (e.g.cf-connecting-ip). Default is the rightmostX-Forwarded-Forentry.INSTANCE_SYNC_TIMEOUT_MS: master only. Time limit for one sync request, including the slave's apply. Default60000, clamped to5000–300000.
The README environment table now also documents the existing AUTH_ALLOW_OAUTH_ROLE_FROM_CLAIMS, INSTANCE_SYNC_RATE_MAX, INSTANCE_SYNC_RATE_WINDOW_MS, INSTANCE_SYNC_MAX_BYTES and CLICKHOUSE_RETENTION_DAYS.
INSTANCE_SLAVES fields:
syncPublicKey: the slave's full base64 sync public key, compared byte for byte (recommended).syncKeyId: the 16-lowercase-hex key id. This is a 64-bit fingerprint, so prefersyncPublicKey.
Entries with an invalid URL or sync key are skipped with Skipping INSTANCE_SLAVES entry <index>: <reason>. Example: {"name":"replica","url":"https://replica.example.com","token":"<token>","syncPublicKey":"<slave's sync public key>"}
REST endpoints (admin):
PUT /api/v1/instances/{id}: edit an instance (name, base URL, token, enabled). Changing the token keeps its key pin.GET /api/v1/instances/sync-key: this instance's own sync key id and public key.PUT/DELETE /api/v1/instances/{id}/sync-key-pin: pin a verified key ({"publicKey":"<base64>"}) for an instance, or reset its pin.GET /api/v1/instances/sync-key-pins: list every pin with the slaves that use it.PUT/DELETE /api/v1/instances/sync-key-pins?url=<slave base URL>: pin or reset one by URL (forINSTANCE_SLAVESentries and slaves not added yet).
All of these are in the OpenAPI spec.
Maintenance
- Lint on the TypeScript 6 API. Lint runs typescript-eslint on the TypeScript 6 API next to TypeScript 7, following Microsoft's side-by-side setup:
typescriptis aliased to@typescript/typescript6, and TypeScript 7 is installed as@typescript/native.bun run typecheckstill uses TypeScript 7. - Docs updated. The README (Upgrade Notes, Rotating SESSION_SECRET, Instance Sync and Sync key pinning, WAF, forward-auth Non-standard ports and Login rate limits, User Roles, and the environment table),
.env.example,SECURITY.md, the landing page and the API security review's remediation table all reflect these changes. The GitHub wiki is updated as well. - CI. Dependabot auto-merge now applies only to patch/minor
bunupdates in PRs Dependabot opened. Majors, GitHub Actions, Go modules and images wait for review. The js-yaml override is bumped to 4.3.2. - Tests. Unit and integration: 137 files, 1994 tests. E2E: 889 tests.
Changes
Full Changelog: v1.12.0...v1.13.0