diff --git a/documentation/tls/overview.md b/documentation/tls/overview.md index d8b6020..73893d2 100644 --- a/documentation/tls/overview.md +++ b/documentation/tls/overview.md @@ -59,10 +59,33 @@ Use the first when Sōzune should own the certificates, the second when the back Force HTTP traffic to HTTPS — see [Redirects](/documentation/middleware/redirects). -## What's not configurable +## TLS versions and ciphers + +Harden the HTTPS listener under `proxy.https.tls`. All fields are optional; each absent one keeps Sōzu's default. + +```yaml +proxy: + https: + listen_address: 443 + tls: + min_version: "1.3" # refuse TLS 1.2 entirely + max_version: "1.3" # optional upper bound (>= min_version) + ciphers: # rustls names, both TLS 1.2 and 1.3 suites + - "TLS13_AES_256_GCM_SHA384" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" +``` + +| Field | Description | +|---|---| +| `min_version` | Lowest TLS version accepted: `"1.2"` or `"1.3"`. | +| `max_version` | Highest accepted, same values; must be `>= min_version`. | +| `ciphers` | Allowed cipher suites, by **rustls** name — both TLS 1.3 (`TLS13_*`) and TLS 1.2 (`TLS_ECDHE_*`) go in this one list. | -The following are not currently exposed by Sōzune; they fall back to Sōzu defaults: +**`ciphers` is a single list across both versions.** It maps to the only cipher input Sōzu's worker reads, so listing only TLS 1.2 suites leaves no TLS 1.3 suite enabled — include the 1.3 suites you want too. An unrecognised name is dropped with a log line; an all-unrecognised list fails the HTTPS worker at startup. + +**These are listener-wide.** Sōzu applies versions and ciphers at bind time, so every hostname served on the HTTPS port shares them — they cannot vary per route. An invalid version (unknown value, `max_version` below `min_version`) fails startup rather than being silently ignored. + +## What's not configurable -- Cipher suites -- Minimum TLS version +- Per-route TLS options — versions and ciphers are a property of the listener, not the route (see above). - Manual certificate injection — ACME is the only source. There is no path to provide a self-signed cert, a wildcard purchased elsewhere, or a cert managed by another tool. diff --git a/src/config.rs b/src/config.rs index f5088dc..1d56b46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -644,6 +644,36 @@ pub struct HttpsConfig { /// HTTP/2 negotiation on the TLS listener. #[serde(default)] pub http2: Http2Config, + /// TLS protocol versions and cipher selection on the listener. + #[serde(default)] + pub tls: TlsOptions, +} + +/// TLS hardening for the HTTPS listener. These are **listener-wide**: Sōzu (and +/// the TLS stack under it) applies protocol versions and ciphers at bind time, +/// so every hostname served on the port shares them — they cannot vary per +/// route. All fields default to `None`, leaving Sōzu's defaults in place. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +pub struct TlsOptions { + /// Lowest TLS version accepted. `None` keeps Sōzu's default. Accepts + /// `"1.2"` or `"1.3"`. Set to `"1.3"` to refuse TLS 1.2 entirely. + #[serde(default)] + pub min_version: Option, + /// Highest TLS version accepted. `None` keeps Sōzu's default. Same values + /// as `min_version`; must be `>= min_version`. + #[serde(default)] + pub max_version: Option, + /// Cipher suites to allow, by rustls name — both TLS 1.3 suites + /// (`"TLS13_AES_256_GCM_SHA384"`) and TLS 1.2 suites + /// (`"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"`) go in this one list. `None` + /// keeps Sōzu's default set. A name Sōzu doesn't recognise is dropped with + /// a log line; an all-unrecognised list fails the HTTPS worker at startup. + /// + /// Note the coupling: this is the only cipher input Sōzu reads, across both + /// versions. Listing only TLS 1.2 suites therefore leaves no TLS 1.3 suite + /// enabled — include the 1.3 suites you want too. + #[serde(default)] + pub ciphers: Option>, } /// HTTP/2 listener settings. Both fields default to `None`, which leaves @@ -918,6 +948,7 @@ impl Default for HttpsConfig { listen_address: default_https_port(), error_pages: BTreeMap::new(), http2: Http2Config::default(), + tls: TlsOptions::default(), } } } diff --git a/src/proxy/sozu/mod.rs b/src/proxy/sozu/mod.rs index f47883c..9378695 100644 --- a/src/proxy/sozu/mod.rs +++ b/src/proxy/sozu/mod.rs @@ -26,8 +26,8 @@ use sozu_command_lib::{ ActivateListener, AddBackend, Cluster, ListenerType, LoadBalancingAlgorithms, LoadBalancingParams, PathRule, QueryMetricsOptions, RemoveBackend, Request, RequestHttpFrontend, RequestTcpFrontend, RequestUdpFrontend, ResponseStatus, RulePosition, - SocketAddress, UdpClusterConfig, WorkerRequest, WorkerResponse, request::RequestType, - response_content::ContentType, + SocketAddress, TlsVersion, UdpClusterConfig, WorkerRequest, WorkerResponse, + request::RequestType, response_content::ContentType, }, }; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -452,6 +452,8 @@ pub fn start_sozu_proxy(inputs: ProxyInputs, config: &ProxyConfig) -> anyhow::Re )); apply_listener_error_pages(&mut https_builder, &config.https.error_pages, "HTTPS"); apply_listener_http2(&mut https_builder, &config.https.http2); + apply_listener_tls_options(&mut https_builder, &config.https.tls) + .map_err(|e| anyhow::anyhow!("Invalid HTTPS TLS options: {e}"))?; // Behind the gate the worker only ever sees loopback connections, losing the // real client IP. So the gate prepends a PROXY-v2 header carrying the true // peer, and the worker is told to expect it — otherwise client-IP matching, @@ -2394,6 +2396,68 @@ fn apply_listener_http2(builder: &mut ListenerBuilder, http2: &crate::config::Ht } } +/// Parse a config TLS version string (`"1.2"` / `"1.3"`) into Sōzu's enum. +fn parse_tls_version(s: &str) -> anyhow::Result { + match s { + "1.2" => Ok(TlsVersion::TlsV12), + "1.3" => Ok(TlsVersion::TlsV13), + other => anyhow::bail!("unsupported TLS version `{other}` (accepted: \"1.2\", \"1.3\")"), + } +} + +/// Apply listener-wide TLS options (versions, ciphers) to the HTTPS builder. +/// +/// Sōzu takes an explicit *list* of enabled versions, not a range, so a +/// `min`/`max` pair is expanded into the versions it spans. Only 1.2 and 1.3 +/// are in play (Sōzune never enables older ones), which keeps the expansion to +/// three cases. A `max < min` pair is rejected rather than silently emptied. +fn apply_listener_tls_options( + builder: &mut ListenerBuilder, + tls: &crate::config::TlsOptions, +) -> anyhow::Result<()> { + let min = tls + .min_version + .as_deref() + .map(parse_tls_version) + .transpose()?; + let max = tls + .max_version + .as_deref() + .map(parse_tls_version) + .transpose()?; + + // Only touch versions when the operator pinned at least one bound; absent + // leaves Sōzu's default (`[TlsV12, TlsV13]`). + if min.is_some() || max.is_some() { + // Default the open bound to the widest Sōzune supports. + let lo = min.unwrap_or(TlsVersion::TlsV12) as i32; + let hi = max.unwrap_or(TlsVersion::TlsV13) as i32; + if hi < lo { + anyhow::bail!( + "TLS max_version must be >= min_version (got min={:?}, max={:?})", + tls.min_version, + tls.max_version + ); + } + let versions: Vec = [TlsVersion::TlsV12, TlsVersion::TlsV13] + .into_iter() + .filter(|v| { + let n = *v as i32; + n >= lo && n <= hi + }) + .collect(); + builder.with_tls_versions(versions); + } + + // Sōzu reads ciphers only from `cipher_list` (TLS 1.2 and 1.3 alike, by + // rustls name); its `cipher_suites` field is never consulted by the worker, + // so everything goes through this one call. + if let Some(ciphers) = &tls.ciphers { + builder.with_cipher_list(Some(ciphers.clone())); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -2410,6 +2474,16 @@ mod tests { builder.to_tls(None).expect("to_tls should succeed") } + fn tls_listener_with_options( + tls: &crate::config::TlsOptions, + ) -> anyhow::Result { + let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(0, 0, 0, 0, 8443)); + apply_listener_tls_options(&mut builder, tls)?; + builder + .to_tls(None) + .map_err(|e| anyhow::anyhow!("to_tls: {e}")) + } + #[test] fn http2_default_advertises_h2_and_http11() { let cfg = tls_listener_with_http2(&Http2Config::default()); @@ -2417,6 +2491,73 @@ mod tests { assert!(cfg.alpn_protocols.iter().any(|p| p == "http/1.1")); } + #[test] + fn tls_options_default_leaves_both_versions() { + // No bound pinned → Sōzu's default (1.2 + 1.3) stays. + let cfg = tls_listener_with_options(&crate::config::TlsOptions::default()).unwrap(); + assert!(cfg.versions.contains(&(TlsVersion::TlsV12 as i32))); + assert!(cfg.versions.contains(&(TlsVersion::TlsV13 as i32))); + } + + #[test] + fn tls_min_1_3_drops_1_2() { + let tls = crate::config::TlsOptions { + min_version: Some("1.3".into()), + ..Default::default() + }; + let cfg = tls_listener_with_options(&tls).unwrap(); + assert_eq!(cfg.versions, vec![TlsVersion::TlsV13 as i32]); + } + + #[test] + fn tls_max_1_2_drops_1_3() { + let tls = crate::config::TlsOptions { + max_version: Some("1.2".into()), + ..Default::default() + }; + let cfg = tls_listener_with_options(&tls).unwrap(); + assert_eq!(cfg.versions, vec![TlsVersion::TlsV12 as i32]); + } + + #[test] + fn tls_max_below_min_is_rejected() { + let tls = crate::config::TlsOptions { + min_version: Some("1.3".into()), + max_version: Some("1.2".into()), + ..Default::default() + }; + let err = tls_listener_with_options(&tls).unwrap_err(); + assert!( + err.to_string() + .contains("max_version must be >= min_version") + ); + } + + #[test] + fn tls_unknown_version_is_rejected() { + let tls = crate::config::TlsOptions { + min_version: Some("1.1".into()), + ..Default::default() + }; + let err = tls_listener_with_options(&tls).unwrap_err(); + assert!(err.to_string().contains("unsupported TLS version")); + } + + #[test] + fn tls_ciphers_are_forwarded_as_cipher_list() { + // rustls names, and forwarded to `cipher_list` — the only cipher field + // Sōzu's worker actually reads. + let tls = crate::config::TlsOptions { + ciphers: Some(vec!["TLS13_AES_256_GCM_SHA384".into()]), + ..Default::default() + }; + let cfg = tls_listener_with_options(&tls).unwrap(); + assert_eq!( + cfg.cipher_list, + vec!["TLS13_AES_256_GCM_SHA384".to_string()] + ); + } + #[test] fn http2_alpn_override_forces_http11_only() { let http2 = Http2Config { diff --git a/tests/e2e/05-tls-h2.sh b/tests/e2e/05-tls-h2.sh index fc52afe..24954ca 100755 --- a/tests/e2e/05-tls-h2.sh +++ b/tests/e2e/05-tls-h2.sh @@ -31,3 +31,36 @@ else fail "TLS h2: no h2 in ALPN negotiation (full log below)" echo "$alpn_log" | sed -n '/ALPN/p;/SSL connection/p' | head -10 fi + +# --- Listener TLS version floor (proxy.https.tls.min_version: "1.3") ------- +# Version negotiation happens before certificate validation, so a 1.2-only +# client is rejected at the handshake even though we serve no valid cert. This +# proves the min_version floor is applied to the listener. +log "[05] TLS: min_version 1.3 rejects a TLS 1.2 client" + +v12=$(curl -k -s -v --tlsv1.2 --tls-max 1.2 --max-time 3 \ + "https://127.0.0.1:$HTTPS_PORT/" 2>&1 || true) +# A rejected 1.2 client fails at the handshake: either a protocol-version alert +# or the listener closing the connection (unexpected eof / TLS connect error). +# What must NOT appear is a certificate-stage error, which would mean the +# version was accepted and negotiation got past it. +if echo "$v12" | grep -qiE "alert protocol version|no protocols available|version too low|unexpected eof|tls connect error|handshake fail|tlsv1 alert" \ + && ! echo "$v12" | grep -qi "certificate"; then + pass "TLS 1.2 client is rejected by the 1.3-only listener" +else + fail "TLS 1.2 client was not rejected (min_version not applied?)" + echo "$v12" | sed -n '/SSL/p;/alert/p;/TLS/p' | head -8 +fi + +# A 1.3 client gets past version negotiation (it then fails on the cert, which +# is expected — we only care that the version floor let it through). +log "[05] TLS: min_version 1.3 admits a TLS 1.3 client" + +v13=$(curl -k -s -v --tlsv1.3 --max-time 3 \ + "https://127.0.0.1:$HTTPS_PORT/" 2>&1 || true) +if echo "$v13" | grep -qiE "alert protocol version|no protocols available|version too low"; then + fail "TLS 1.3 client was wrongly rejected on version" + echo "$v13" | sed -n '/SSL/p;/alert/p' | head -8 +else + pass "TLS 1.3 client passes version negotiation" +fi diff --git a/tests/e2e/run-all.sh b/tests/e2e/run-all.sh index 1684b70..8e1b555 100755 --- a/tests/e2e/run-all.sh +++ b/tests/e2e/run-all.sh @@ -92,6 +92,8 @@ proxy: "404": "

sozune custom 404

" https: listen_address: $HTTPS_PORT + tls: + min_version: "1.3" tcp: - name: tcpecho listen: $TCP_ECHO_PORT