bunkerity / bunkerity/bunkerweb
[BUG] 1.6.14: customcert wildcard SNI fallback overrides Let's Encrypt for services with USE_CUSTOM_SSL=no
- Dominant language
- Python
- Stars
- 10.9k
- Forks
- 643
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 42
Description
## Description
In 1.6.14 the `customcert` plugin gained a wildcard SNI fallback. It registers the wildcard SANs of **every** custom certificate as a **global** fallback, and then serves that certificate for any matching SNI — **without checking whether the requested service actually enabled `USE_CUSTOM_SSL`**.
Because `customcert` runs before `letsencrypt` in the `ssl_certificate` phase, a service configured with `USE_CUSTOM_SSL=no` + `AUTO_LETS_ENCRYPT=yes` is now served the *other* service's custom wildcard certificate. Its valid Let's Encrypt certificate exists in the cache but is never used.
This is a regression: 1.6.11, 1.6.12 and 1.6.13 matched SNI exactly and are unaffected.
The practical impact is severe when the custom certificate is not publicly trusted (e.g. a Cloudflare Origin CA certificate, which is the documented setup for Cloudflare-proxied services). Affected sites then present an untrusted certificate. With `STRICT_TRANSPORT_SECURITY` enabled there is no click-through, so those sites become **completely unreachable** in every browser.
## Version
- BunkerWeb **1.6.14** (Docker integration, `MULTISITE=yes`)
- Upgraded from 1.6.11 — worked correctly before the upgrade
- Confirmed by diffing `core/customcert/customcert.lua` across images: `1.6.11`, `1.6.12`, `1.6.13` contain **0** occurrences of `wildcard`; `1.6.14` contains 34.
Introduced by #3745 ("Fix custom certificate wildcard fallback and extract TLS hook", merged 2026-07-29, first shipped in 1.6.14 on 2026-08-21). The feature itself is useful — the problem is only that the fallback ignores the requested service's `USE_CUSTOM_SSL` setting, so it also fires for services that never asked for a custom certificate.
## Reproduction
Two services in the same multisite instance, sharing a parent domain:
| Service | `USE_CUSTOM_SSL` | `CUSTOM_SSL_CERT` | `AUTO_LETS_ENCRYPT` |
|---|---|---|---|
| `a.example.com` | `yes` | a cert whose SAN list contains `*.example.com` | `no` |
| `b.example.com` | `no` | — | `yes` (`LETS_ENCRYPT_CHALLENGE=dns`, `USE_LETS_ENCRYPT_WILDCARD=yes`) |
Then:
```bash
# b.example.com opted out of custom SSL, yet gets a.example.com's custom certificate
openssl s_client -connect 127.0.0.1:443 -servername b.example.com /dev/null \
| openssl x509 -noout -issuer -subject
```
**Expected:** the Let's Encrypt certificate (it exists at
`/var/cache/bunkerweb/letsencrypt/etc/live/example.com/fullchain.pem` and is valid).
**Actual:** the custom certificate of `a.example.com`.
Note the apex domain `example.com` is *not* affected, because `resolve_wildcard_base()` requires a sublabel — which makes the bug look inconsistent at first glance.
**Nothing is logged.** The wrong certificate is selected silently: there is no warning, no error, and no `customcert` entry in the nginx error log at `LOG_LEVEL=notice` or `info`. From the logs the instance looks perfectly healthy. That is why this report shows handshake output rather than log excerpts — the TLS handshake is the only place the bug is visible.
## Root cause
`core/customcert/customcert.lua`
1. `init()` collects wildcard bases from every custom certificate into a single global table, keyed only by the base domain — the owning service is not recorded:
```lua
if wildcard_certificates then
local wildcard_bases, wildcard_err = get_wildcard_bases(data[1], data[2])
...
for base in pairs(wildcard_bases) do
wildcard_certificates[base] = { cert_chain, priv_key }
end
end
```
2. `ssl_certificate()` falls back to that table whenever the exact SNI lookup misses. There is no check against the requested service's `USE_CUSTOM_SSL`:
```lua
data, err = self.internalstore:get("plugin_customcert_" .. normalized_server_name, true)
...
elseif data then
return self:ret(true, "certificate/key data found", data)
end
local wildcard_bases, bases_err = self.internalstore:get("plugin_customcert_wildcard_bases", true)
if wildcard_bases then
local base = resolve_wildcard_base(normalized_server_name, wildcard_bases)
if base then
...
elseif wildcard_certificates[base] then
return self:ret(true, "wildcard certificate/key data found for " .. base, wildcard_certificates[base])
end
end
end
```
3. `core/order.json` runs `customcert` first in the `ssl_certificate` phase:
```json
["customcert", "letsencrypt", "selfsigned"]
```
Since `customcert` returns a certificate, `letsencrypt` never gets a chance.
An explicit `USE_CUSTOM_SSL=no` should be authoritative for that service. Currently it is silently overridden by an unrelated service's certificate.
## No way to opt out via settings
`core/customcert/plugin.json` exposes only `USE_CUSTOM_SSL`, `CUSTOM_SSL_CERT_PRIORITY`, `CUSTOM_SSL_CERT`, `CUSTOM_SSL_KEY`, `CUSTOM_SSL_CERT_DATA`, `CUSTOM_SSL_KEY_DATA`. There is no setting to disable the wildcard fallback — the only `WILDCARD` setting in the whole core (`USE_LETS_ENCRYPT_WILDCARD`) belongs to `letsencrypt` and has no effect here. So this cannot be worked around from the web UI; the only configuration-level workarounds are reissuing the custom certificates without wildcard SANs, or putting the affected hosts behind the proxy the origin certificate belongs to.
## Suggested fix
Make the wildcard fallback respect the requested service's opt-out. Concretely: record the hostnames of services that did *not* enable custom SSL, and skip the fallback for them, letting `letsencrypt` (and then `selfsigned`) handle the handshake.
One subtlety: the scoped variable store only holds settings that differ from the defaults, so a service left at `USE_CUSTOM_SSL=no` may be absent from `get_multiple_variables()` entirely. The exclusion list therefore needs to be completed from the global `SERVER_NAME` list — every configured service without an exact custom certificate of its own.
Patch against 1.6.14, running in production on my instance since applying it — all 56 services now get the correct certificate, and the wildcard fallback still works for hosts that are *not* configured as services, so the new feature keeps working as intended):
```diff
function customcert:init()
local ret_ok, ret_err = true, "success"
local wildcard_certificates = {}
+ local wildcard_excluded = {}
if has_variable("USE_CUSTOM_SSL", "yes") then
local multisite, err = get_variable("MULTISITE", false)
if not multisite then
@@
for server_name, multisite_vars in pairs(vars) do
+ -- remember every hostname of services that opted out of custom SSL
+ if multisite_vars["USE_CUSTOM_SSL"] ~= "yes" and server_name ~= "global" then
+ local opted_out = multisite_vars["SERVER_NAME"] or server_name
+ for key in opted_out:gmatch("%S+") do
+ wildcard_excluded[normalize_hostname(key)] = true
+ end
+ end
if multisite_vars["USE_CUSTOM_SSL"] == "yes" and server_name ~= "global" then
@@
ret_err = "custom cert is not used"
end
+ -- services left at the default USE_CUSTOM_SSL=no may be absent from the scoped store,
+ -- so complete the exclusion list from the global SERVER_NAME list
+ if next(wildcard_certificates) then
+ local all_servers = get_variable("SERVER_NAME", false)
+ if all_servers then
+ for key in all_servers:gmatch("%S+") do
+ local host = normalize_hostname(key)
+ local exact = self.internalstore:get("plugin_customcert_" .. host, true)
+ if not exact then
+ wildcard_excluded[host] = true
+ end
+ end
+ end
+ end
+
+ local ok, err = self.internalstore:set("plugin_customcert_wildcard_excluded", wildcard_excluded, nil, true)
+ if not ok then
+ return self:ret(false, "error while caching custom certificate wildcard exclusions : " .. err)
+ end
+
local wildcard_bases = {}
for base in pairs(wildcard_certificates) do
table.insert(wildcard_bases, base)
end
@@
- local ok, err = self.internalstore:set("plugin_customcert_wildcard_bases", wildcard_bases, nil, true)
+ ok, err = self.internalstore:set("plugin_customcert_wildcard_bases", wildcard_bases, nil, true)
if not ok then
return self:ret(false, "error while caching custom certificate wildcard bases : " .. err)
end
@@ function customcert:ssl_certificate()
return self:ret(true, "certificate/key data found", data)
end
+ -- never fall back to a wildcard custom certificate for a service that did not enable
+ -- custom SSL; let the next ssl_certificate plugin (letsencrypt) handle it
+ local excluded, excluded_err = self.internalstore:get("plugin_customcert_wildcard_excluded", true)
+ if not excluded and excluded_err ~= "not found" then
+ return self:ret(false, "can't get custom certificate wildcard exclusions : " .. excluded_err)
+ end
+ if excluded and excluded[normalized_server_name] then
+ return self:ret(true, "custom certificate is not used (service did not enable USE_CUSTOM_SSL)")
+ end
+
local wildcard_bases, bases_err = self.internalstore:get("plugin_customcert_wildcard_bases", true)
```
An alternative (or additional) fix would be a dedicated setting to disable the wildcard fallback, so operators who hit this can opt out without patching.
Happy to open a PR if the approach looks right.
## Related
- #3745 — the PR that introduced the wildcard fallback.
- #3835 — also a 1.6.14 custom-certificate report, but a different issue (scheduler loses the UI custom certificate across reboots); mentioning it only to keep the two apart.
## Note on the status page referenced by the issue template
The template asks to check the live service status at `status.bunkerweb.io` first. I was not able to: that host currently serves a self-signed placeholder certificate, so browsers refuse the connection.
```
$ openssl s_client -connect status.bunkerweb.io:443 -servername status.bunkerweb.io
Contributor guide
Assessment
This issue has not been assessed yet.