CertMate handles TLS private keys, ACME account credentials, and DNS-provider API tokens. We take security reports seriously and treat them as the highest priority class of issue in this project.
Only the latest minor release line receives security fixes. Operators running
older lines should upgrade to the latest patch on 2.56.x before reporting —
fixes for retired lines are out of scope.
| Version | Supported |
|---|---|
2.56.x |
Yes |
< 2.56 |
No (please upgrade) |
The supported line moves forward with each 2.x.0 release; everything below
it is retired at that moment. This file is updated by the release tooling, so
the line above is always the one currently receiving fixes.
Do not open a public GitHub issue or pull request for security reports. Public disclosure before a fix is available puts every operator running CertMate at risk.
Open a private security advisory: https://github.com/fabriziosalmi/certmate/security/advisories/new
This is the recommended path. It keeps the report private, gives both sides a durable record, lets the maintainer coordinate a fix in a private fork, and the published advisory becomes the CVE record once a fix ships.
If you cannot use GitHub advisories (no GitHub account, the form is unavailable, the bug is in the advisory flow itself), email fabrizio.salmi@gmail.com with:
- A descriptive subject prefixed with
[certmate-security] - The CertMate version you tested against (output of
docker image inspector the running container's/healthendpoint includes the version) - Reproduction steps or a proof-of-concept
- The impact you observed and the impact you expect
Encrypted email is welcome but not required.
| Stage | Target |
|---|---|
| Acknowledgement of the report | within 72 hours |
| Triage decision (accept / decline / need more info) | within 7 days |
Fix landed on main (for accepted reports) |
within 30 days for high/critical severity |
| Public advisory + release | coordinated with the reporter |
If a report sits in accept state for longer than the target above, send a
gentle nudge to the same channel.
In scope:
- The CertMate application code in this repository (Python backend, JS dashboard, Flask routes, certbot integration, storage backends, deploy hooks).
- The Docker image published from this repository.
- The default configuration shipped in the repository.
Out of scope:
- Third-party dependencies whose vulnerabilities should be reported upstream (certbot, acme.sh, the Azure / AWS / GCP SDKs, etc.). We will of course pull in the fixed version once it lands upstream.
- Operator misconfiguration (running CertMate as root, exposing the dashboard on a public interface without auth, granting overly broad DNS API tokens, etc.). The documentation calls these out; if you find a scenario where the default configuration is unsafe, that is in scope.
- Issues that require physical access or local root on the host running CertMate.
Deploy hooks run operator-configured shell commands on the CertMate host after a successful issuance or renewal (to reload nginx/HAProxy, copy the certificate into place, run custom scripts). This is intentional: a user with the admin role can configure commands that CertMate then executes, so admin access is equivalent to shell access on the host.
Hook commands are validated to reject shell metacharacters and references to
CertMate's own infrastructure secrets (settings.json, api_bearer_token,
client_secret, vault_token, .env). That validation is defence-in-depth
against accidental footguns — it is not a sandbox and is not intended to
contain a malicious admin. The issued certificate's own private key
(privkey.pem) is deliberately not blocked, because installing it is the
normal job of a deploy hook.
Treat the admin role as highly privileged: grant it only to trusted operators, and prefer scoped, non-admin API keys for automation that only needs to create or download certificates.
DNS provider API tokens and the OIDC client secret are stored in
settings.json as cleartext JSON, in a file written 0600. The permission is
correct and is not the interesting part: that file is what gets backed up,
copied between hosts, mounted into a container and attached to a bug report.
Any of those fields can instead name where its value lives, the way
API_BEARER_TOKEN_FILE already does for the bearer token. Beside a field, write
<field>_file with a path or <field>_env with a variable name:
{
"dns_providers": {
"cloudflare": {
"accounts": {
"prod": { "api_token_file": "/run/secrets/cloudflare_api_token" }
}
}
},
"oidc": { "client_secret_env": "CERTMATE_OIDC_CLIENT_SECRET" }
}The value is read at the moment it is used — to run certbot, or to build the OIDC client — and is never written back, so a settings save does not turn a reference into a stored secret. Listing accounts and rendering the settings page do not read it at all: an account counts as configured because the reference is present, not because the secret could be read.
Details worth knowing before relying on it:
- A reference wins over a value in the same field. Adding
api_token_filenext to an existingapi_tokenswitches over immediately; the stale literal is ignored rather than silently preferred. _filewins over_envif both are present.- Trailing whitespace is stripped, because
docker secretandkubectl create secret --from-fileboth produce files ending in a newline. - A reference that resolves to nothing is refused, not treated as empty. A missing mount fails issuance with a log line naming the field and the path, rather than sending a blank token to the DNS provider.
This does not encrypt anything and is not a substitute for protecting the data volume. What it does is let the highest-value secrets live wherever the deployment already keeps secrets, instead of in the file that travels.
POST /api/settings accepts the documented configuration keys only. These
cannot be written through it, because each is a privilege or an execution
surface, and each has an endpoint of its own with its own validation and audit:
| Key | Where it is changed |
|---|---|
users |
POST /api/users |
api_keys |
POST /api/keys |
deploy_hooks |
POST /api/deploy/config |
local_auth_enabled |
POST /api/auth/config (admin) |
oidc |
POST /api/auth/oidc/settings (admin) |
api_bearer_token, api_bearer_token_hash |
API_BEARER_TOKEN or API_BEARER_TOKEN_FILE, read at startup |
A refused key is answered 400, with a hint naming the endpoint.
The bearer token and API keys are stored as HMAC-SHA256 digests keyed by
SECRET_KEY, so settings.json on its own does not give them away. When
SECRET_KEY is not set, CertMate generates the key into data/.secret_key,
beside settings.json, and a copy of the whole data/ directory then holds
both. Set SECRET_KEY (or SECRET_KEY_FILE) outside the data volume to keep
them apart.
Every change to settings, users, API keys, deploy hooks and the local-auth
switch is recorded with the operator and the source IP, and so are
authorization denials. The readable log is logs/audit/certificate_audit.log,
one entry per line: a timestamp, the logger and the level, then the entry as a
JSON object. The tamper-evident record is the hash chain under data/audit/;
see Compliance and
Audit Logging in the API reference.
Until the first operator credential exists (a local admin with local auth
enabled, API_BEARER_TOKEN, or OIDC), every request is served as admin to
anyone who can reach the instance. So during setup CertMate only
bootstraps: it creates the first admin and turns login on. API keys and
further users are refused with 409 SETUP_BOOTSTRAP_ONLY until setup is
complete, because anything created in that window would be created by whoever
was there and would outlive it. API keys that an older version let be created
during setup stay valid, but are flagged in Settings > API Keys and in the
startup log until an operator confirms or revokes each one.
Status: resolved by the certbot 5.8 stack (#103). CertMate now pins
certbot==5.8.0, acme==5.8.0, josepy==2.2.0, cryptography==50.0.2 and
pyopenssl==26.4.0. certbot and acme 5.8.0 require cryptography>=47, and
pyOpenSSL 26.4.0 requires >=49,<51, so pip refuses a pin outside that window.
None of the four advisories below applies to cryptography 50.0.2: measured
with pip-audit against the installed set, 46.0.7 carried four advisories
and 50.0.2 carries none. The text below stays as the record of why the pins were
held for as long as they were, and as the assessment of what was reachable in
the meantime; an advisory against cryptography that is not listed here has
not been assessed, and should be treated as new.
Until that migration, CertMate pinned cryptography==46.0.7 and
pyopenssl==26.0.0. Every version that would clear the advisories below
needed a pyOpenSSL that broke the then-pinned ACME stack, so the pins were held
deliberately: the alerts were open by choice, not by oversight. One reason
blocked all of them, and one fix cleared all of them (issue #103); both are
described once, after the advisories.
Four advisories were held by that constraint. The first is a flaw in the
OpenSSL statically linked into the cryptography wheel; the other three,
published 2026-08-03, are in cryptography's own code and are reached only
through specific APIs. That difference is what makes the reachability argument
below hold for the three, and it is why they are analysed separately rather
than folded into the first.
CertMate currently pins cryptography==46.0.7, which is flagged HIGH by
GHSA-537c-gmf6-5ccf: wheels of cryptography prior to 48.0.1 statically
link an OpenSSL vulnerable to CVE-2026-45447 (heap use-after-free in
PKCS7_verify(), OpenSSL security advisory of 2026-06-09). The fixed
versions are 48.0.1 and later; there is no backported fix on the 46.x
or 47.x lines (46.0.7 is the final 46.x release).
Why the bump was blocked (on the 2.10.0 stack). The constraint chain, verified against PyPI metadata and a clean-room install on 2026-07-07:
acme==3.3.0requirespyOpenSSL>=25.0.0;josepy==1.13.0also depends on pyOpenSSL. These pins, together withcertbot==2.10.0, are deliberately held (see issue #103).- The first pyOpenSSL release whose metadata admits cryptography
48.xis26.2.0(cryptography>=46.0.0,<49). The pinnedpyopenssl==26.0.0caps at<47, and26.1.0caps at<48. - pyOpenSSL
26.2.0(2026-05-04) removed the long-deprecatedOpenSSL.crypto.X509Extension. acme==3.3.0referencescrypto.X509Extensionin the signature ofacme.crypto_util.gen_ss_cert()withoutfrom __future__ import annotations, so the name is evaluated at import time. Withpyopenssl>=26.2.0,import acme.crypto_util— and therefore everycertbotinvocation — fails withAttributeError: module 'OpenSSL.crypto' has no attribute 'X509Extension'(reproduced withcryptography==48.0.1+pyopenssl==26.2.0:certbot --versioncrashes).- The same applies to
cryptography49.x, which needspyopenssl>=26.3.0.
In short: every cryptography version that fixes the GHSA requires a pyOpenSSL that breaks the pinned ACME stack at import.
Re-verified 2026-08-08 against cryptography==50.0.0 (Dependabot #516), and
the situation has become more dangerous, not less.
- pip now accepts the combination.
pip install certbot==2.10.0 josepy==1.13.0 cryptography==50.0.0resolves and installs cleanly, pullingpyopenssl==26.4.0. The dependency metadata no longer refuses it, so the guard rail that used to stop this bump at resolution time is gone. The failure has moved from install time to run time: an image builds green and then dies the first time it tries to issue a certificate. - The failure is now
X509Req, notX509Extension. pyOpenSSL 26.4.0 has dropped both (hasattr(OpenSSL.crypto, 'X509Req') == False), andX509Reqis the one that breaks first. certbotitself no longer starts. Not merelyacme.crypto_util:certbot --versionraisesAttributeError: module 'OpenSSL.crypto' has no attribute 'X509Req'fromcertbot/_internal/main.py:21. CertMate drives certbot as a CLI subprocess, so this is the entire issuance path, not a library nicety.
So the pin was doing more work than it had, not less: nothing upstream enforced it any more. The real fix was the certbot 5.x stack migration (#103), which is what removed the hazard rather than guarding it: acme 5.8.0 does not reference the removed pyOpenSSL API, and certbot/acme/pyOpenSSL now state the window they work in.
Mitigation / actual exposure. The vulnerable code path is
PKCS7_verify() (PKCS#7 / S/MIME signature verification):
- No CertMate code path reaches it. Nothing in
modules/orapp.py, nor in the pinnedcertbot/acme/josepystack, performs PKCS#7 or S/MIME verification, and pyca/cryptography's Python API does not expose PKCS#7 signature verification at all (it can only create and serialize PKCS#7 structures). The vulnerable function is present in the statically linked library but unreachable from CertMate. - The operations that do run inside the statically linked OpenSSL — X.509 parsing and issuance, CSR handling, the private CA, client certificates, OCSP/CRL checks, and audit-log signing — do not touch the PKCS#7 routines.
- CertMate's TLS network I/O (gunicorn/Flask,
requeststo ACME and DNS provider APIs) uses Python'ssslmodule, which links the interpreter's own OpenSSL, not the copy inside the cryptography wheel.
Three further advisories against cryptography <= 46.0.7 were published on
2026-08-03. They are a different shape from the one above: the defect is in
cryptography's own Rust and Python code, not in the linked OpenSSL, so each
is reached through one named API and through nothing else.
| Advisory | Severity | Defect | Reached through | First fixed in |
|---|---|---|---|---|
| GHSA-g6cj-pr64-35w5 | HIGH | duplicate self-signed intermediates cause exponential X.509 path building | cryptography.x509.verification |
49.0.0 |
| GHSA-jwv3-5hgf-82ww | HIGH | PKCS#7 EnvelopedData decryption exposes a Bleichenbacher oracle |
the PKCS#7 decryption API | 50.0.0 |
| GHSA-m2h6-j472-rp4c | MEDIUM | the verifier accepts wildcard DNS names, allowing escape from permittedSubtrees name constraints |
cryptography.x509.verification |
49.0.0 |
Actual exposure: none of the three is reachable. Two need the X.509
verifier (PolicyBuilder / ClientVerifier / ServerVerifier), one needs
PKCS#7 EnvelopedData decryption. None of those is called anywhere in the
shipped stack — verified across modules/, app.py and the pinned
certbot==2.10.0 / acme==3.3.0 / josepy==1.13.0, none of which references
x509.verification, PolicyBuilder, ClientVerifier, ServerVerifier,
pkcs7_decrypt_der, pkcs7_decrypt_pem, pkcs7_decrypt_smime,
PKCS7EnvelopeBuilder or EnvelopedData.
This paragraph used to say that nothing referenced pkcs7 at all, and as of
v2.34.0 that is no longer true: modules/core/revocation.py calls
pkcs7.load_der_pkcs7_certificates and pkcs7.load_pem_pkcs7_certificates to
read an AIA caIssuers payload, which CAs publish as a certificates-only
PKCS#7 bundle (.p7c). Those two loaders parse a bag of certificates; the
advisory is about decrypting EnvelopedData, which is a different structure,
a different API and a different code path. The conclusion is unchanged, but
the reason had to be stated precisely rather than as "the module is untouched"
— a blanket claim that a later change can quietly falsify, as this one did.
What CertMate does use from cryptography is X.509 parsing and building,
OCSP request/response handling and CRL parsing (modules/core/revocation.py),
Fernet, hashes, key serialization, RSA and Ed25519 key generation, and
PBKDF2. The .pfx export
(modules/core/storage_backends.py:175) calls serialization.pkcs12, which
is PKCS#12 — a different structure and a different code path again. CertMate
never decrypts PKCS#7; it reads certificate bundles from it and writes
PKCS#12.
The constraint had tightened, not loosened. Clearing all three needed
cryptography>=50.0.0, and 50.0.0 was the exact version the re-verification
above proved fatal on the old stack: it installed cleanly and then
certbot --version died on X509Req. The version that would close these
alerts was the version that broke issuance.
Resolution. The certbot 5.x migration (issue #103) moved acme to a release
that drops the removed pyOpenSSL API, with josepy>=2 and a newer certbot line,
and pyopenssl>=26.2.0 and cryptography>=48.0.1 unblocked together. Measured
on the stack that ships: the real-certificate end-to-end suite passes against
Let's Encrypt staging, a certificate issued by certbot 2.10.0 renews under 5.8.0
and the reverse, and pip-audit reports no advisory against cryptography.
Dependabot no longer holds cryptography or pyopenssl: a bump is tested (the
image build runs certbot --version) instead of held. The reachability
arguments above are kept because they were the assessment while the alerts were
open; they are not what protects the image now.
Two checks reconcile this section with reality, because a list that claims to be complete and is not is worse than no list.
scripts/check_advisories.py(scheduled) asks GitHub which Dependabot alerts the repository is carrying — open, or dismissed astolerable_risk— and fails when one of them is not named above. It was written after this section documented one advisory while three more, published against the same pin, sat open and unmentioned through a release.scripts/check_resolved_advisories.py(on every pull request, insidesecurity-scan) asks OSV about the packages that are actually installed in the image, captured withpip freezefrom inside it, and fails the same way.
The second exists because the first cannot see what ships. Dependabot reads the
manifests, and so does any scanner pointed at the repository; both then resolve
each transitive constraint to the lowest version it admits, which is not
what pip install produces. Measured against v2.29.0 on 2026-09-08:
| package | manifest scan reports | actually in the image |
|---|---|---|
pyjwt |
2.9.0 — 7 advisories | 2.13.0 — none |
requests |
2.9.2 — 5 | 2.34.2 — none |
pygments |
2.9.0 — 2 | not installed |
idna |
3.9.0 — 1 | 3.19 — none |
filelock |
3.9.1 — 2 | 3.32.5 — none |
protobuf |
4.25.9 — 1 | 7.36.1 — none |
All eighteen were false positives. pyjwt is the one worth naming: it arrives
transitively through msal (Azure DNS), the floor msal declares is 2.9.0,
and an audit reported those seven advisories as shipping in the default image.
They do not — so no reachability argument is needed for them, and none is
recorded here. If you are looking at a manifest-level alert against a package
that is not in the table above, check the version in the image before assessing
it.
Scanning the resolved set gives the answer this section can actually stand
behind: four advisories, all cryptography==46.0.7, all documented above.
This section records what the dependency pinning does and does not guarantee (issue #686). It began as the record of why the locks carried no hashes; they carry them now, and the reasons that changed are kept below. What CertMate does and does not claim about the packages in its images:
What is guaranteed. requirements.lock and requirements-minimal.lock
hold the fully resolved install set for the two main variants — every
transitive package at an exact version — and the image installs from them. That
part is no longer build-day luck: requirements.txt pins 42 packages and
resolves to 118, so 76 of the packages in a published image used to be whatever
the index served that day, with no record in the repository of which. The locks
are resolved by uv for the two published architectures
(scripts/regenerate_lockfiles.sh), never by hand, and the image installs them with
pip. A regeneration starts from the pins already in the file, so it moves what the
change under review needs moved and nothing else.
Every direct dependency in requirements.txt and
requirements-minimal.txt is pinned to an exact version, and CI fails if a
package is pinned to two different versions across files, if a certbot plugin
is pinned above the certbot pin, or if any advertised
EXTRA_REQUIREMENTS combination stops resolving. The base image is pinned by
digest. Published images carry an SPDX SBOM and SLSA provenance, so what
shipped is auditable after the fact. Extras layers install under
-c requirements.constraints (the lock's pins), so an optional backend cannot
move a pin the base layer holds, transitive ones included.
Hashes, and where they stop. requirements.lock, requirements-minimal.lock
and requirements-build.lock (the builder's own pip, setuptools, wheel and
packaging) carry the sha256 of every file the index publishes for each pinned
version, and the image installs them with --require-hashes. A file that is
not one of those is refused and the build stops: a re-published or substituted
wheel at a pinned version, from a compromised index or maintainer account, does
not reach the image. That covers every Python package in the published image,
which is built from requirements.lock with no extras: its 126 packages are
the lock's 123 plus pip, wheel and packaging from the build lock. Before this,
pip was pinned but setuptools, wheel and packaging were whatever the index
served on build day.
Where it stops: an image built with EXTRA_REQUIREMENTS installs the extras on
top, constrained by requirements.constraints (or
requirements-minimal.constraints): the lock's pins, without the hashes. The
extras cannot move a pin the lock holds, but the packages they add are pinned
only as far as their own file pins them and are not hash-verified. The optional
variants that have no lock (the DNS and storage sets used as
REQUIREMENTS_FILE) still resolve at build time.
Why the split. pip's hash checking is all-or-nothing per install, and a
hash in a constraints file switches it on too. Constraining the extras with the
hashed lock was measured to fail: -c requirements.lock -r requirements-infisical-storage.txt ends in "Hashes are required" for the
package the lock does not have. This section used to give that as the reason to
have no hashes at all, because the alternatives were a lock per extras
combination or a half-hashed install whose errors name the wrong cause and get
routed around. The .constraints file avoids both: each pip invocation in the
build is either fully hashed (the build tools, the lock) or not hashed at all
(the extras), never mixed. CI installs the test requirements the same way.
Two reasons once given here, measured and withdrawn. "Two architectures
resolving different binary wheels": for requirements.txt and
requirements-minimal.txt the resolution is byte-identical on linux/amd64 and
linux/arm64, which is why one lockfile per variant is enough. "A hash is per
wheel, so identical versions can still need different hashes": uv pip compile --generate-hashes lists the hashes of every file of the version, so the hashed
lock is also identical for both architectures. scripts/regenerate_lockfiles.sh
resolves both and refuses to write if the versions or the hashes ever differ,
so both stay measurements rather than memories.
Keeping the locks honest. Installing from a lock means a bump to
requirements.txt has no effect until the lock is regenerated: a security patch
could merge, go green, and never reach the image.
tests/test_the_transitive_set_is_written_down.py fails when a direct pin and
its lock disagree, so that is a red check rather than a silent non-delivery.
The opposite risk — a transitive frozen at a version that later turns out to be
vulnerable — is covered by scripts/check_resolved_advisories.py, which asks
OSV about the set actually installed in the built image on every build. Refresh
the locks with scripts/regenerate_lockfiles.sh whenever a pin moves, and
whenever that advisory scan reports something the direct pins do not explain.
A rebuild is not expected to be bit-identical. The runtime stage installs
three OS packages (bash, curl, tini) with an unpinned apt-get install,
so two builds of the same commit, days apart, can carry different versions of
them. That is deliberate: pinning the three would break the build on every
base-image bump, and a genuinely reproducible OS layer needs a snapshot mirror,
which is a project rather than a line. The consequence is recorded here so
nobody reads a digest mismatch between two builds of one commit as evidence of
tampering — it is the expected outcome. What the image actually contains is
answerable from the SBOM that ships with it.
What the image contains is an allowlist, not a denylist. The runtime stage
names what it copies — app.py, modules/, templates/, static/,
scripts/ and the requirements files — rather than copying the tree and
subtracting. Until v2.29.0 it did the latter, and the published image carried
the Helm chart, the client SDK sources, the MCP server, the monitoring assets,
a demo directory and a licensing PDF. None of it was reachable by the process,
but a denylist ships whatever nobody remembered to add to it.
tests/test_image_ships_only_what_it_runs.py checks the built image, not the
Dockerfile.
If you are threat-modelling against this, the honest summary is: integrity for the Python packages of the published image (each file verified against the hash the lock recorded when it was reviewed), auditability for the rest. The OS packages and the packages an extras build adds are what the SBOM lets you inspect after the fact, not what the build verifies.
We coordinate disclosure with the reporter. For high or critical severity:
- A fix lands on a private branch.
- A release is prepared and the security advisory is drafted in parallel.
- The release tag, the advisory publication, and (where appropriate) the CVE request all go out together.
- The advisory credits the reporter unless they request anonymity.
Thank you for taking the time to make CertMate safer for the operators who depend on it.