fix(shared-ols): unmapped Host gets 421, not a 200 that hides a dead site

The shared-OLS catch-all (`map _health *`) served html/index.html --
HTTP 200, 11 bytes, "shared-ols" -- to any Host no customer vhost claimed.
Three live customer sites (joshuaknapp.net, streamers.channel,
blog.anti-social.online) sat in exactly that state for ~2 months on whp01
and no monitor noticed, because every uptime check asks "is it 200?" and
it was. A tier-wide catch-all that answers 200 makes a missing vhost
indistinguishable from a working site.

An unmapped Host now gets 421 Misdirected Request with a short generic
body. 421 is semantically exact (the server cannot produce a response for
the requested authority) and, unlike 404, cannot be confused with a normal
answer from a real site.

The discriminator is the request path plus the client address, NOT the
Host -- the vhost is selected by the listener map, so by the time these
rules run the Host is no longer available to branch on:

  * `/healthz` from an internal client address (loopback, RFC1918) -> 200 "ok"
  * everything else, every path, every Host, both listeners -> 421

The 421 for `/` is UNCONDITIONAL: no header, source address or Host talks
this vhost into a 200 there, so the property the change exists to
guarantee does not rest on anything spoofable. The address gate only
hardens /healthz, and X-Forwarded-For cannot be used against it because
HAProxy replaces that header with the real client IP.

Health probes keep passing unchanged. Both forms were run against a
container carrying this change and both exit 0 with "ok":
  curl -fsSk https://127.0.0.1/healthz   (Dockerfile.shared-ols HEALTHCHECK)
  curl -sfk  https://localhost/healthz   (WHP setup-shared-ols.sh --health-cmd)
`docker inspect` reported healthy with failingStreak=0, on a container with
a customer site and on a zero-site container.

Measured on the lab VM against OLS 1.8.4 (the production base image):
  unmapped Host, `/`, :443 and :80   -> 421, 356 bytes, identical for every
                                        unmapped Host (no enumeration signal)
  unmapped Host, any deeper path     -> the same 421
  configured site, both names, :443/:80 -> 200, served normally
  litespeed -t                        -> 0 [ERROR] lines (warnings only, and
                                        only about the lab fixture's uid/gid)

Two OLS behaviours were measured rather than assumed, and both shaped the
implementation -- see the comment block in entrypoint-shared-ols.sh:
`context / { type redirect statusCode 421 }` silently degrades to a 302
with an unexpanded Location, and the `errorpage 421` body is fetched as a
fresh request through the same rewrite rules (so it needs a %{THE_REQUEST}
guard, since %{IS_SUBREQ} and %{ENV:REDIRECT_STATUS} are not populated).

The old index.html is removed, not just bypassed: if these rules ever
stopped applying, `context /` would fall back to the docRoot index, and
with no index.html that is a 403 -- wrong-but-loud, rather than a 200 that
is wrong-and-silent.

Known consumer to land alongside this: whp-monitoring's
probe_shared_ols_catchall() currently detects the catch-all by matching
`200` + body `shared-ols`, a signature this change deletes. It must also
accept 421, or the detector silently stops detecting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:44:01 -07:00
co-authored by Claude Opus 5
parent ba9650ee45
commit 11c02d94ec
3 changed files with 139 additions and 6 deletions
+7
View File
@@ -55,6 +55,13 @@ EXPOSE 80 443
## Health: the entrypoint renders a catch-all _health vhost serving /healthz, so
## this passes from boot (zero customer sites) onward. Self-signed :443.
##
## MUST stay on /healthz, and must stay a LOOPBACK request. That vhost answers
## 421 for every other path/Host so an unmapped customer hostname can never look
## "up" to a monitor; /healthz answers 200 only for an internal client address
## (loopback here). Probing `/` instead would fail the healthcheck and restart
## the whole shared tier. WHP's setup-shared-ols.sh overrides this with the
## equivalent `curl -sfk https://localhost/healthz`; keep the two in step.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsSk https://127.0.0.1/healthz || exit 1