mkellermann97 8844c9151d security(#633): TLS bootstrap fails closed instead of serving plaintext on the TLS port
When TLS was the intended posture and the cert could not be read or generated,
the bootstrap printed a WARNING and then bound plaintext HTTP on the port meant
for TLS - a silent downgrade of the management plane (TLS clients got
"Invalid http version: \x16\x03\x01..." while the service reported itself
healthy). The os.path.exists() gate also collapsed EACCES into ENOENT, so a
present-but-unreadable cert was reported "missing" and generation tried to write
over it. The config/ssl mkdir/chmod/migrate ran at import time, so a root-run
importer could plant root-owned certs and lock the service user out - the trigger
behind the reported case.

Adapted from #637 by @SpyrosPsarras, with follow-up hardening from an adversarial
review:

- Fail closed: the inline TLS setup in main() is now _resolve_ssl_context(), which
  returns None only for a reverse proxy or the explicit PEGAPROX_ALLOW_PLAINTEXT=1
  opt-in, and otherwise raises SystemExit. Both plaintext bind sites (gevent + the
  Flask dev-server fallback) read that single context, so there is no path left
  where TLS is intended yet cleartext binds.
- Unreadable != missing: readability is probed by opening the file; only a true
  ENOENT leads to generation, and a present-but-unreachable cert is never
  overwritten. The error names the path, dir owner/mode and the process uid/gid.
- Import-time side effects in constants.py now run only when config/ is owned by
  the current euid; update.sh repairs ownership on upgrade.
- (MK) readable != loadable: _unloadable() actually load_cert_chain()s the pair in
  the resolver, so a corrupt/mismatched cert gets the same actionable fail-closed
  message instead of a raw ssl.SSLError crash downstream. Uses the byte-identical
  call the real bind uses, so it can never reject a cert the server would accept.
- (MK) a half-present pair (one file there, the other ENOENT) fails closed instead
  of regenerating over the surviving half.
- (MK) systemd StartLimitBurst so a permanently-broken cert lands in `failed`, not
  an endless 5s crash-loop.

Tests: tests/test_ssl_bootstrap.py drives the real _resolve_ssl_context; the
fixture now uses a real throwaway pair so the loadability path is exercised, plus
corrupt-cert and half-pair cases. 18/19 pass here; the one gap is the
generation-SUCCESS case, blocked by this box being a broken-pyOpenSSL env (it too
fails closed) - it and a real systemd/TLS-bind E2E are owed on a proper host.
Closes #633.
2026-08-01 00:31:13 +02:00
..