๐ Recent release history (last 2 versions)
[2.205] - 2026-08-07
Security
- ACME proxy endpoints now verify resource ownership โ the proxy's authorization, challenge, order-status and certificate-download endpoints verified the JWS signature but never checked that the requesting account owned the resource: any account registered on the proxy could read any other account's authorization status and challenge details, poll their orders, and download their certificates by enumerating the base64-encoded upstream identifiers (the native ACME server was not affected โ it has enforced this binding all along). Every order-scoped proxy endpoint now enforces the owner binding recorded at new-order (account id and/or client JWK thumbprint), the same check finalize already performed: a foreign account gets
unauthorized(403), and an owner-bound resource whose requester identity cannot be derived fails closed. Because challenge and authorization URLs live in disjoint namespaces on most CAs, a challenge is attributed to its order through the authorization URL the upstream CA itself returns in theLink rel="up"header, never by guessing from the URL shape. Resources no longer tracked by any proxy order (e.g. the order row was deleted) now return 404 instead of being fetched upstream; orders created before ownership tracking existed carry no binding and are served as before. Reported by @gb-123-git (#260). - Trusted-proxy gate vs ProxyFix
REMOTE_ADDRrewrite โ withUCM_BEHIND_PROXY=1(orUCM_TRUSTED_PROXY_HOPS>0), Werkzeug's ProxyFix rewritesREMOTE_ADDRfromX-Forwarded-Forbefore the trusted-proxy gate ran. Two consequences: requests arriving through a configured reverse proxy failed the check (every nginx-proxied Web UI request was 403'd with "Untrusted X-Forwarded-Host from non-proxy client", and mTLS / EST client-cert headers from the proxy were ignored); and a direct attacker who could reach the backend could pass the gate by sendingX-Forwarded-For: 127.0.0.1, unlockingSSL_CLIENT_*/X-SSL-Client-*header handling used for client-certificate authentication. Trust decisions now key on the real TCP peer preserved inwerkzeug.proxy_fix.orig(utils.trusted_proxy.immediate_peer_addr()),client_ip()trusts ProxyFix's hop-aware result when it rewroteREMOTE_ADDRinstead of re-parsing client-supplied XFF entries (the legacyX-Real-IPfallback is kept when ProxyFix found nothing to apply), spoof-attempt warning logs report the real peer, and the mTLS middleware reuses the shared gate instead of its own duplicated (equally affected) check.
Fixed
- Navigation highlight now derives the active item from the URL path โ the active page was derived from the first URL segment under a "segment === nav item id" naming convention, so every route that breaks the convention never highlighted and its sidebar group never auto-expanded:
/key-recovery(nav idkeyRecovery) and the SSH pages/ssh/casand/ssh/certificates(nav idsssh-cas/ssh-certificates) were permanently unhighlighted, whilescep-config,est-configandtsa-configonly worked through a hand-maintained exception map. The active item is now resolved by matching each nav item'spath(exact match first, then longest prefix so detail pages like/cas/123still highlight their section), which keeps the highlight correct by construction for any future nav item. The Key Recovery contextual help panel, gated on the same broken id, now opens as well. - EST settings no longer reject a fresh CA selection when the previously configured CA was deleted โ deleting a CA leaves its
est_ca_refidvalue behind, andGET /api/v2/est/configkeeps returning that stale refid (reportingca_id: null); the settings page PATCHes the whole object back, so the stale refid arrived alongside the freshca_idand the update handler, which validatedca_refidfirst, answeredCA not foundbefore ever looking at the valid selection. When both fields are sent,ca_idis now authoritative; aca_refid-only request is validated exactly as before. - SSH CA TTL fields accept the duration format the UI advertises โ the create/edit placeholders say "e.g. 24h, 7d, 365d", but
default_ttl/max_ttlwere cast with a bareint(), so365dwas rejected withinvalid literal for int()(and the import path stored the raw string, crashing later at issuance). All three entry points (create, update, import) now normalize throughutils.duration.parse_duration_seconds()โ plain numbers stay valid as seconds,s/m/h/d/w/ysuffixes are accepted, and malformed values fail with a clear 400 naming the accepted formats. - Approved issuance (policy workflow) now honors the certificate template โ when a deferred certificate request finalized after approval, the legacy approval issuance path ignored the template's Key Usage / Extended Key Usage, used hardcoded cert-type profiles, always signed with SHA-256 (ignoring the template digest), fell back to its own request defaults instead of the template's key type/validity, stored the resulting private key unencrypted, dropped the
template_idlinkage, and skipped the CA-expiration sanity check. The approval path now applies the template's extensions template / digest / defaults like the direct issuance path (#226), encrypts the issued private key viaencrypt_private_key, preserves the template link withtemplate_overrides(#258), and refuses validity beyond the CA's own end-of-life.
Added
- Custom Command DNS provider for ACME DNS-01 โ a new DNS provider type (
custom_command) running admin-configured local commands for TXT record create/delete, with record details passed through environment variables (DOMAIN,RECORD_NAME,RECORD_VALUE,TTL,ACTION). This is the escape hatch for DNS providers not natively supported (#249 โ e.g. nicmanager or any of lego's 220 drivers reached through a small wrapper script); lego itself ships no record-level CLI, so a direct binary call was not possible. Hardened: absolute binary path required, no shell (argv-split, no pipes), hard timeout (5โ300 s, default 60), output truncated in error reports. The command binaries must exist and be executable or the provider test reports it. - dns-persist-01 ACME challenge support (draft-ietf-acme-dns-persist-01) โ the built-in ACME server can now authorize DNS identifiers via a persistent TXT record at
_validation-persist.<fqdn>bound to the requester's ACME account (accounturi) and an issuer-domain-name, so clients renew without writing DNS records. Opt-in under ACME settings, off by default: persistent validation ties issuance capability to the account key for the record's lifetime (draft ยง7.2), and the UI states this on toggle.policy=wildcardis honored for wildcard/subdomain scope (draft ยง5/ยง6),persistUntilrejects new validations past its timestamp, malformed records produce ACMEmalformederrors, account mismatchesunauthorized. Issuer-domain-names come from the configured CAA identifiers, falling back to the ACME public hostname. Challenges still expire per the normal ACME authorization lifecycle. acme.dns01_nameserversacceptshost:portentries โ the optional authoritative-resolver override (used by DNS-01 and dns-persist-01 validation) can now point at resolvers not listening on port 53 (e.g. a loopback-only BIND or a dnsmasq instance on an alternate port). Comma-separated as before; plain IPs still work.- Certificates issued from a template now record how they diverged from it โ key type, validity and digest are template defaults that a request may legitimately override (issue #226 shipped that behavior); the issued certificate keeps its template link and now records which of those values were explicitly overridden (#258, option 2 debated there). The certificate detail shows the template name with a "modified from template" indicator listing the divergent fields, and the certificate list gains a Template filter to find diverged certificates. The record is written at issuance and frozen โ editing the template later does not rewrite history. The value is exposed as
template_overrides(list of field names) in the certificate API responses; renewals at par keep it.
Fixed
- mTLS reverse-proxy certificate headers never honored โ the mTLS middleware,
login_mtls(),/api/v2/auth/methods, and/api/v2/mtls/enrollall matchedX-SSL-Client-*names againstdict(request.headers), whose keys WSGI reconstructs title-cased (X-Ssl-Client-Verify), so the exact-caseinchecks never matched and client-certificate authentication through nginx/apache proxy headers silently never fired (the designed spoof-attempt warnings were equally dead). All four sites now userequest.headersdirectly, which is case-insensitive.
[2.204] - 2026-08-01
Security
- Direct private-key export now requires an admin-only scope โ exporting a certificate's private key (
include_key, or apkcs12/pfx/key/jksformat) required onlywrite:certificates, which theoperatorrole holds by default โ the very role that would otherwise go through the approval-gated Key Recovery flow. That made the four-eyes approval pointless: an operator could simply export the key directly, unapproved. Private-key export is now gated behind the admin-onlyread:private_keysscope; roles without it must use Key Recovery. CA private-key export was already restricted (write:cas) and is unchanged. Upgrade note: operators can no longer export certificate private keys directly โ grantread:private_keysto a role (or use an admin account / admin-scoped API key) where direct export is required, otherwise route it through Key Recovery. Reported in #232. - ACME finalize rejects certificate requests carrying identities the order never validated โ only DNS and IP subject alternative names were compared against the order's identifiers, and the CSR's SAN extension was copied verbatim onto the issued certificate: a client that legitimately passed a challenge for its own domain could also smuggle an email address (rfc822Name) or a Windows UPN (otherName) into the certificate โ S/MIME impersonation, or AD logon as another user against a CA trusted for smartcard logon. Every SAN entry must now match a validated order identifier; any other general-name type is refused with a
badCSRerror. Contributed by @heidrickla (#242). - Key strength is enforced on every issuance path โ the RSA โฅ 2048 / P-256+ floor documented for enrollment was only applied by SCEP and EST; a weak-key CSR (e.g. RSA-1024) was signed without complaint through the admin Sign-CSR endpoint and ACME finalize. The floor now lives in the signing service itself, so all paths inherit it. Upgrade note: weak-key CSRs now fail everywhere with an explicit message; there is no opt-out โ re-key the requesting system. Contributed by @heidrickla (#243).
- CSR-supplied Extended Key Usages are capped to the certificate type's profile โ whatever EKUs a CSR carried were copied onto the certificate (minus only OCSPSigning/timeStamping), so a protocol enrollee could mint itself a code-signing certificate through ACME. Protocol paths now honor only the EKUs the resolved certificate type permits;
anyExtendedKeyUsageand Smartcard Logon are never honored from a CSR. Operators keep full control through the explicit extra-EKUs option when signing. A follow-up closes the resulting corner cases: a CSR whose EKUs are all disallowed no longer yields a certificate with the EKU extension omitted entirely (which would be unrestricted) on any issuance path, SCEP included, and renewals preserve the certificate's existing EKUs at par instead of narrowing them to the default profile (#254). Contributed by @heidrickla (#243). - Sub-CA creation through Sign-CSR is gated and pathLen-clamped โ signing a CSR as
intermediate_caonly required certificate-signing permissions and copied the CSR's BasicConstraints (pathLen included) unchecked. It now additionally requireswrite:cas, and the sub-CA's pathLen is clamped below the parent's (refused outright under a pathLen-0 parent), per RFC 5280 ยง4.2.1.9. A follow-up extends the same clamp to sub-CAs created viaPOST /cas(previously left unbounded under a constrained parent) and to the default CSR shape that carries no BasicConstraints (#254). Contributed by @heidrickla (#243). - The private-address guard applies to ACME IP-address orders โ both challenge validators skipped the SSRF check for RFC 8738
ipidentifiers (it was gated on the identifier being a DNS name), so a deployment that had explicitly disabledallow_private_ipsstill fetched challenges from private and link-local addresses, including cloud metadata endpoints. Deployments using the default (private IPs allowed โ the on-prem use case) are unaffected. A follow-up additionally refuses cloud-metadata and link-local targets unconditionally โ even when private IPs are allowed โ and rejects malformed DNS identifiers before validation (#256). Contributed by @heidrickla (#244). - Missing encryption keys are loud, and can be made fatal โ without a configured database encryption key, integration secrets are protected by a key derived from the machine id (world-readable, routinely captured in backups and images), and without private-key encryption enabled, CA and certificate keys are stored unencrypted โ both silently. Both conditions are now logged at startup, and two opt-in environment variables (
UCM_REQUIRE_DB_ENCRYPTION_KEY,UCM_REQUIRE_KEY_ENCRYPTION) refuse to run without explicit keys. Upgrade note: startup behavior is unchanged for existing deployments and existing data stays readable; the new startup error on installs that never enabled private-key encryption is informational โ enable it under Settings โ Security. A follow-up makes the require-flags refuse at startup (rather than on first secret use), treats an invalid key as fatal instead of silently falling back to plaintext, refuses the disable-encryption path up front, and accepts1/yes/onlike every other toggle (#252). Contributed by @heidrickla (#245). - mTLS, API-key, TSA, CSRF, SSH and OCSP hardening โ issuing an mTLS client certificate now requires
write:user_certificates(it accepted any authenticated role, and fell back to an arbitrary CA when none was configured); API keys are capped at auth time to their owner's current effective permissions, so a key minted by a since-demoted operator no longer keeps its old scopes (upgrade note: keys whose owner lost roles or groups lose those scopes immediately โ re-mint under an appropriate owner if an integration breaks); the mTLS enrollment endpoints lost their CSRF exemption; SSH certificate extension requests are validated against the CA's configured extension list; OCSP nonces are bounded per RFC 8954; a new opt-in TSA setting can require a dedicated timestamping certificate (the endpoint itself remains public, as RFC 3161 requires); and 2FA recovery codes work again โ login compared a truncated prefix, so account recovery by code always failed. A follow-up expands a storedread:*/write:*API-key scope to the owner's current permissions instead of dropping it on demotion, preserves an explicitly empty SSH extension policy (it was collapsing back to the full default set), and requireswrite:cason the explicit-CA mTLS path (#253). Contributed by @heidrickla (#246). - Deployment and CI defaults hardened โ the standalone database initializer now flags the seeded admin for a forced password change like every other bootstrap path; the Docker environment example no longer ships a working database password; the development compose file binds the debugger to loopback; the Trivy CI action is pinned to a release SHA; OPNsense imports verify TLS by default (upgrade note: API callers that relied on the old default must pass
verify_ssl: falseexplicitly โ the UI checkbox is unchanged); and EST behind a TLS-terminating reverse proxy requires an explicit verification-success header instead of treating its absence as success (upgrade note: proxies must forwardssl_client_verify; clients fall back to HTTP Basic authentication otherwise). A follow-up completes the compose fix (the base file still published the application ports on 0.0.0.0), aligns the OPNsense verify-TLS default in the UI, and repairs a destructive database-reset path that dropped the schema without reseeding an admin (#255). Contributed by @heidrickla (#248). - The ACME Terms-of-Service text no longer interpolates HTML โ in the admin preview or on the public terms page โ the preview was built by a hand-rolled regex sanitizer whose output went to
dangerouslySetInnerHTML; it did not escape double quotes and never validated link schemes, so admin-supplied ToS text was stored XSS against any operator viewing the ACME settings page. The public, unauthenticated/acme/termspage rendered the same stored text server-side with the identical missing-quote escape, so a quote in a ToS URL could inject attributes there too. The preview now renders through ReactMarkdown with an http/https/mailto scheme allowlist (bare-URL autolinking, single-newline line breaks and list styling are preserved), and the server-side renderer escapes quotes before autolinking. Both are defence in depth โ the CSP already blocked inline script execution on modern browsers. Contributed by @heidrickla (#247). - ACME finalize now validates the CSR subject, not just its SANs โ the SAN-type check added in #242 was bypassable through the certificate subject. A CSR with no SAN extension still had a SAN synthesized from its subject when signed, so an order that only proved a DNS name could finalize with
emailAddress=ceo@victim.comin the subject and receive a certificate asserting that address โ an identity the account never proved, which email-based client-certificate identity mapping (mTLS, EAP-TLS, VPN) trusts. Separately, only the first commonName was ever compared to the order, so a secondCN=admin.victim.comrode onto the certificate unvalidated โ and because the name UCM stores and displays is read from the reversed RFC 4514 subject, that smuggled name even became the identity shown for the certificate. Finalize now requires every commonName to be a validated order identifier and refuses any emailAddress in the subject, with the samebadCSRerror. Contributed by @heidrickla (#251).
Fixed
- User groups can be given permissions from the UI, and the help text is accurate โ group permissions have been enforced since 2.201 (a member's effective permissions are their role's unioned with those of their groups), but the group form only exposed name and description, so there was no way to actually set them โ and the Users/Groups and RBAC help described a non-existent "assign a role to a group" model. The group form now has a permission picker (limited to the grantable set, never
admin:*/*), the group detail panel lists a group's permissions, and the help now describes how group permissions actually work. Reported in #233. - A certificate with more than one commonName is recorded under the right one โ the stored and displayed
subject_cnwas taken by splitting the subject's RFC 4514 string on,and keeping the firstCN=token, but that format emits RDNs in reverse order, so the token found was the last commonName. A certificate whose subject wasCN=web.example.com, CN=admin.example.comwas listed, searched and exported asadmin.example.com. The same split also ignored RFC 4514's\,escape, so a comma inside another attribute (O=Example, Inc.) could truncate the name. Both recorders โ the admin signing path and SCEP โ now read the commonName from the subject itself, which is also how ACME validates it, so the name checked and the name recorded can no longer disagree. Existing rows are unchanged; re-issuing a certificate corrects its recorded name. Contributed by @heidrickla (#257). - Backend error messages reach the UI again โ RFC 7807
application/problem+jsonerror bodies were read as plain text by the frontend's content-type check, so the backend's specific message (e.g. which password rule failed at user creation) was discarded in favor of a generic per-status string. Reported in #234, contributed by @Hemsby (#237). - Settings save buttons only submit their own section โ every Save button on the General, Security, Backup and Audit tabs sent the entire settings object, so an invalid field in one card silently blocked saving an unrelated one, and the Password Policy and 2FA cards had no Save button of their own at all. Each button now owns exactly its card's fields, and both cards got their own button. Contributed by @Hemsby (#238).
- The Microsoft CA connection Save button carries its icon again, matching the other buttons in the row. Contributed by @Hemsby (#239).
- CRL scheduler no longer errors on offline CAs โ a CA taken offline in password-protected mode keeps its (passphrase-encrypted) key in the database, so the scheduler still selected it for CRL regeneration and raised "Password was not given but private key is encrypted" on every cycle. Offline CAs are now skipped (with a debug log), for both base and delta CRLs, consistent with every other signing path.
Added
- Per-EAB domain restrictions โ an EAB credential can be limited to the domains it may request certificates for:
*(any),*.example.com(all sub-domains), or an explicit list; an empty list blocks issuance entirely. Enforced on ACME new-order and new-authz, on both the built-in ACME server and the proxy. Existing credentials stay unrestricted (*) after upgrade and new credentials default to unrestricted, so nothing breaks. Restrictions are only meaningful when EAB is required (acme_eab_required), since otherwise clients can register without an EAB credential (migration 069). Implements #241, contributed by @gb-123-git (#259). - SCEP profiles โ multiple enrollment endpoints โ named profiles served at
/scep/<profile>/pkiclient.exe, each bound to its own CA, optional certificate template (whose key usage, extended key usage and validity govern issuance), challenge password (encrypted at rest, with the same expiry window as the global challenge) and approval policy. Device certs, user certs or per-tenant enrollments each get a dedicated URL instead of sharing one global endpoint; the existing/scep/pkiclient.exeendpoints keep serving the global configuration unchanged. Managed from a new Profiles tab on the SCEP page (migration 068). Requested in #228, and groundwork for Intune dynamic challenge validation. - Delegated OCSP responder certificates renew automatically โ a daily task re-issues a responder certificate before it expires (same key pair and extensions, renewed at par) and rebinds the CA's responder configuration to the new certificate, so a short-lived OCSP signing certificate (e.g. the 90-day system template) rotates without manual action. Enabled by default; can be disabled and the renewal window tuned via configuration. Requested in #226.
- Template key type and validity are honored at issuance via the API โ an issuance request carrying a
template_idbut nokey_type/validity_daysnow inherits them from the template (the UI already prefilled them); explicit request values still win. Requested in #226.
Full history: CHANGELOG.md
Installation
Docker (Recommended)
# From Docker Hub
docker pull neyslim/ultimate-ca-manager:2.206-rc1
# Or from GitHub Container Registry
docker pull ghcr.io/neyslim/ultimate-ca-manager:2.206-rc1
# Run
docker run -d -p 8443:8443 \
-e SECRET_KEY=$(openssl rand -hex 32) \
--name ucm neyslim/ultimate-ca-manager:2.206-rc1Debian/Ubuntu
wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.206-rc1/ucm_2.206.rc1_all.deb
sudo dpkg -i ucm_2.206.rc1_all.deb
sudo apt-get install -fFedora/RHEL
wget https://github.com/NeySlim/ultimate-ca-manager/releases/download/v2.206-rc1/ucm-2.206.rc1-1.fc43.noarch.rpm
sudo dnf install ./ucm-2.206.rc1-1.fc43.noarch.rpmSilent/Automated Install
# Skip firewall prompts for CI/automation
sudo UCM_PORT=8443 UCM_FIREWALL=no dpkg -i ucm_2.206.rc1_all.debDefault Credentials
- Username:
admin - Password:
changeme123
Change the password immediately after first login!
Documentation
- Installation Guide
- API Documentation