Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions documentation/tls/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 31 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// 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<Vec<String>>,
}

/// HTTP/2 listener settings. Both fields default to `None`, which leaves
Expand Down Expand Up @@ -918,6 +948,7 @@ impl Default for HttpsConfig {
listen_address: default_https_port(),
error_pages: BTreeMap::new(),
http2: Http2Config::default(),
tls: TlsOptions::default(),
}
}
}
Expand Down
145 changes: 143 additions & 2 deletions src/proxy/sozu/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<TlsVersion> {
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> = [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::*;
Expand All @@ -2410,13 +2474,90 @@ mod tests {
builder.to_tls(None).expect("to_tls should succeed")
}

fn tls_listener_with_options(
tls: &crate::config::TlsOptions,
) -> anyhow::Result<sozu_command_lib::proto::command::HttpsListenerConfig> {
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());
assert!(cfg.alpn_protocols.iter().any(|p| p == "h2"));
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 {
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/05-tls-h2.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions tests/e2e/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ proxy:
"404": "<html><body><h1>sozune custom 404</h1></body></html>"
https:
listen_address: $HTTPS_PORT
tls:
min_version: "1.3"
tcp:
- name: tcpecho
listen: $TCP_ECHO_PORT
Expand Down