fix(server): fail closed when TLS is requested but cannot be established - #221
Merged
Conversation
Closes #200. Motivation ---------- With tls.enabled = true, a failure in the certificate step was logged and then swallowed: the call site flipped cfg.tls.enabled = false and carried on, so the listeners came up as cleartext HTTP on the very ports the operator configured for HTTPS. Every client keeps working — which is exactly why nobody notices — and API keys cross the wire in the clear. The only signal was one error! line. Root cause ---------- xerj-server/src/main.rs:1599-1603 caught the ensure_tls_cert error, logged "falling back to plain HTTP", and disabled TLS; the bind path then served plain HTTP. The neighbouring PEM-load step was already fail-closed (build_tls_config errors abort startup, main.rs:1605-1612); the cert-provisioning step was the one remaining fail-open hole. Interaction with Config::validate (the reachable failure shape) --------------------------------------------------------------- - Config::validate (xerj-common/src/config.rs:146-158) rejects tls.enabled = true with empty cert/key paths, and runs on every config file load (config.rs:106). A config that never went through validate (pure defaults, no file) has TLS off, and no CLI flag or env var turns TLS on (--insecure only turns it off). So TLS-enabled always reaches ensure_tls_cert with NON-empty paths. - If both files exist, the reuse branch cannot fail (the 0600 tightening only warns). If either file is missing, ensure_tls_cert auto-generates a self-signed pair into <data_dir>/xerj.{crt,key} — and generation and the two writes CAN genuinely fail (unwritable or full data dir, obstructed target path, read-only fs). That error was being converted into a silent downgrade. What changed ------------ - main.rs call site: propagate the error with a context line naming the refusal and both ways out (fix the certificate setup, or disable TLS explicitly / --insecure for dev). async_main's Err exits the process non-zero before any listener binds. - Unit test ensure_tls_cert_failure_errors_without_downgrading: an obstructed auto-generation target (<data_dir>/xerj.crt squatted by a directory) must error and must NOT flip tls.enabled off — pins the contract the call site now relies on. - Integration test tests/tls_fail_closed.rs: spawns the real binary (CARGO_BIN_EXE_xerj) with tls.enabled = true and the obstructed cert target; asserts non-zero exit with "TLS"/"refusing to start" on stderr within a deadline. A regressed (downgrading) binary binds its free ports and keeps serving, so the test fails instead of hanging. Reference-coding ---------------- Both peer servers propagate TLS setup errors at startup; neither has a downgrade branch (approach only, no code copied): - meilisearch crates/meilisearch/src/main.rs:198 — `opt_clone .get_ssl_config()?`; a cert/key load failure aborts run_http (option.rs:755-797 errors on bad material rather than returning None). - quickwit quickwit-serve/src/rest.rs:230-235 — `make_tls_server_config(tls_config, alpn_protocols)?` propagates; a TLS misconfig aborts the REST server start. The xc index's xerj-search slice currently returns 0 hits even for single-token probes ("rustls", "tls", "certificate" — verified directly against the value field), so the clones under ~/.xerj-code/corpora/xerj-search were read directly. Verification (all run on this branch) ------------------------------------- - cargo build --release -j 32 -p xerj-server: OK - cargo test --release -j 32 -p xerj-server --bin xerj: 26 passed; 0 failed (tls_tests 6/6, incl. the new one) - cargo test --release -j 32 -p xerj-server --test tls_fail_closed: 1 passed; 0 failed - cargo test --release -j 32 -p xerj-server --no-run: OK - cargo fmt --all --check: clean - Live before/after with the same failing config (obstructed <data_dir>/xerj.crt): the old main binary logged "TLS setup failed … falling back to plain HTTP" and answered cleartext HTTP on the configured ports (ES-compat even solicited an API key over plaintext); this branch's binary exits 1 with "tls.enabled = true but TLS could not be established; refusing to start …" + the write-cert cause chain, and binds nothing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #200.
What
With
tls.enabled = true, a failure in the certificate step (ensure_tls_cert) was logged and swallowed: the call site flippedcfg.tls.enabled = falseand served plain HTTP on the very ports the operator configured for HTTPS. Every client keeps working — which is exactly why nobody notices — and API keys cross the wire in the clear.This PR makes startup fail closed: if TLS was requested and cannot be established, the process exits non-zero with the reason and both ways out (fix the cert setup, or disable TLS explicitly /
--insecurefor dev). The neighbouring PEM-load step (build_tls_config) was already fail-closed; the cert-provisioning step was the one remaining fail-open hole.Live before/after (old binary from main vs this branch)
Old binary,
tls.enabled = truewith the auto-generation target obstructed:then a cleartext probe of the operator's ES-compat port answers and solicits credentials over plaintext:
Fixed binary, same config: exits non-zero before binding anything:
The
Config::validateinteraction (reviewer warning from #200)Config::validate(xerj-common/src/config.rs:146-158) rejectstls.enabled = truewith empty cert/key paths on every config-file load (config.rs:106). Pure defaults (no config file) never runvalidate, but have TLS off — and no CLI flag or env var turns TLS on (--insecureonly turns it off). So TLS-enabled always reachesensure_tls_certwith non-empty paths.<data_dir>/xerj.{crt,key}, whose generation/writes can genuinely fail (unwritable or full data dir, obstructed target, read-only fs). That error was the one being converted into a silent downgrade — it is now fatal.No production path relied on the downgrade: the shipped
xerj.default.tomland the helm chart both defaulttls.enabled = false(the CI shipped-config boot smoke is unaffected), and a helm deploy with a broken cert mount now crashloops with the reason instead of silently serving cleartext — consistent with #213's secure-by-default direction.Tests
mod tls_tests):ensure_tls_cert_failure_errors_without_downgrading— an obstructed auto-generation target must error and must not fliptls.enabledoff; pins the contract the call site now relies on.tests/tls_fail_closed.rs): spawns the real binary (CARGO_BIN_EXE_xerj) withtls.enabled = trueand the obstructed target; asserts non-zero exit naming TLS and the refusal within a deadline. A regressed (downgrading) binary binds its free ports and keeps serving, so the test fails rather than hangs.Reference-coding
Both peer servers propagate TLS setup errors at startup; neither has a downgrade branch (approach only, no code copied):
crates/meilisearch/src/main.rs:198—opt_clone.get_ssl_config()?; a cert/key load failure abortsrun_http(option.rs:755-797errors on bad material rather than returningNone).quickwit-serve/src/rest.rs:230-235—make_tls_server_config(tls_config, alpn_protocols)?propagates; TLS misconfig aborts the REST server start.(The xc index's
xerj-searchslice currently returns 0 hits even for single-token probes —rustls,tls,certificate, verified directly against thevaluefield — so the clones under~/.xerj-code/corpora/xerj-searchwere read directly.)Out of scope
The two "related, same area" observations in #200 — the
bind_address = 0.0.0.0+ TLS-off default posture, and gRPC being h2c even withtls.enabled = true— are GA-posture decisions, not part of this fail-closed fix. If closing #200 should not bury them, they are worth re-filing individually.Verification (run on this branch)
cargo build --release -j 32 -p xerj-server— OKcargo test --release -j 32 -p xerj-server --bin xerj— 26 passed; 0 failed (tls_tests 6/6, incl. the newensure_tls_cert_failure_errors_without_downgrading)cargo test --release -j 32 -p xerj-server --test tls_fail_closed— 1 passed; 0 failed (0.10 s — the fixed binary exits immediately)cargo test --release -j 32 -p xerj-server --no-run— OKcargo fmt --all --check— cleanThe before/after transcripts above are from real runs of the old main binary vs this branch's binary against the byte-identical failing config (exit codes 0-running-forever vs 1; ports probed with curl: cleartext 404/security_exception vs connection refused). The ES-YAML conformance gate runs in CI; this change touches only the startup error path with TLS off by default, so the suite's server boot (
--insecure) is unaffected.