Skip to content

Commit e9ba441

Browse files
committed
Harden systemd listener adoption
1 parent ebd6193 commit e9ba441

6 files changed

Lines changed: 153 additions & 7 deletions

File tree

crates/fluxheim-systemd/examples/activation_ownership_probe.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ fn main() -> io::Result<()> {
66
Some("concurrent") => concurrent_claim_probe(),
77
Some("repeat") => repeat_claim_probe(),
88
Some("failed-validation-retry") => failed_validation_retry_probe(),
9+
Some("oversized-declaration") => oversized_declaration_probe(),
910
_ => Err(io::Error::new(
1011
io::ErrorKind::InvalidInput,
11-
"expected concurrent, repeat, or failed-validation-retry probe",
12+
"expected concurrent, repeat, failed-validation-retry, or oversized-declaration probe",
1213
)),
1314
}
1415
}
@@ -110,6 +111,20 @@ fn failed_validation_retry_probe() -> io::Result<()> {
110111
Ok(())
111112
}
112113

114+
#[cfg(target_os = "linux")]
115+
fn oversized_declaration_probe() -> io::Result<()> {
116+
let error = match fluxheim_systemd::receive_tcp_listeners(1) {
117+
Ok(_) => return Err(io::Error::other("oversized LISTEN_FDS was accepted")),
118+
Err(error) => error,
119+
};
120+
if error.kind() != io::ErrorKind::InvalidInput
121+
|| !error.to_string().contains("LISTEN_FDS declares 129")
122+
{
123+
return Err(error);
124+
}
125+
Ok(())
126+
}
127+
113128
#[cfg(not(target_os = "linux"))]
114129
fn main() -> io::Result<()> {
115130
Err(io::Error::new(

crates/fluxheim-systemd/src/lib.rs

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
1010
#[cfg(target_os = "linux")]
1111
static ACTIVATION_CONSUMED: AtomicBool = AtomicBool::new(false);
1212

13+
#[cfg(target_os = "linux")]
14+
const MAX_ACTIVATION_LISTENERS: usize = 128;
15+
1316
#[cfg(target_os = "linux")]
1417
pub fn receive_tcp_listeners(expected: usize) -> io::Result<Vec<TcpListener>> {
1518
use std::os::fd::{FromRawFd as _, IntoRawFd as _, OwnedFd};
@@ -25,6 +28,8 @@ pub fn receive_tcp_listeners(expected: usize) -> io::Result<Vec<TcpListener>> {
2528
)
2629
})?;
2730

31+
validate_declared_count(expected, activation_listener_declaration()?.as_deref())?;
32+
2833
// `false` is security-critical: environment mutation is unsound once other
2934
// process threads can concurrently read libc's environment storage.
3035
let descriptors =
@@ -71,19 +76,83 @@ pub fn receive_tcp_listeners(expected: usize) -> io::Result<Vec<TcpListener>> {
7176

7277
#[cfg(target_os = "linux")]
7378
fn validated_tcp_listener(index: usize, owned: std::os::fd::OwnedFd) -> io::Result<TcpListener> {
74-
if rustix::net::sockopt::socket_type(&owned)? != rustix::net::SocketType::STREAM {
79+
validate_tcp_listener_properties(
80+
index,
81+
rustix::net::sockopt::socket_type(&owned)?,
82+
rustix::net::sockopt::socket_protocol(&owned)?,
83+
rustix::net::sockopt::socket_acceptconn(&owned)?,
84+
)?;
85+
Ok(TcpListener::from(owned))
86+
}
87+
88+
#[cfg(target_os = "linux")]
89+
fn validate_tcp_listener_properties(
90+
index: usize,
91+
socket_type: rustix::net::SocketType,
92+
protocol: Option<rustix::net::Protocol>,
93+
listening: bool,
94+
) -> io::Result<()> {
95+
if socket_type != rustix::net::SocketType::STREAM {
96+
return Err(io::Error::new(
97+
io::ErrorKind::InvalidInput,
98+
format!("descriptor index {index} is not a stream socket"),
99+
));
100+
}
101+
if protocol != Some(rustix::net::ipproto::TCP) {
75102
return Err(io::Error::new(
76103
io::ErrorKind::InvalidInput,
77-
format!("descriptor index {index} is not a TCP stream socket"),
104+
format!("descriptor index {index} does not use TCP"),
78105
));
79106
}
80-
if !rustix::net::sockopt::socket_acceptconn(&owned)? {
107+
if !listening {
81108
return Err(io::Error::new(
82109
io::ErrorKind::InvalidInput,
83110
format!("descriptor index {index} is not in listening state"),
84111
));
85112
}
86-
Ok(TcpListener::from(owned))
113+
Ok(())
114+
}
115+
116+
#[cfg(target_os = "linux")]
117+
fn activation_listener_declaration() -> io::Result<Option<String>> {
118+
std::env::var_os("LISTEN_FDS")
119+
.map(|value| {
120+
value.into_string().map_err(|_| {
121+
io::Error::new(
122+
io::ErrorKind::InvalidInput,
123+
"LISTEN_FDS is not valid Unicode",
124+
)
125+
})
126+
})
127+
.transpose()
128+
}
129+
130+
#[cfg(target_os = "linux")]
131+
fn validate_declared_count(expected: usize, declared: Option<&str>) -> io::Result<()> {
132+
if !(1..=MAX_ACTIVATION_LISTENERS).contains(&expected) {
133+
return Err(io::Error::new(
134+
io::ErrorKind::InvalidInput,
135+
"expected listener count must be between 1 and 128",
136+
));
137+
}
138+
let declared = declared
139+
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "LISTEN_FDS is missing"))?
140+
.parse::<usize>()
141+
.map_err(|_| {
142+
io::Error::new(
143+
io::ErrorKind::InvalidInput,
144+
"LISTEN_FDS is not a valid integer",
145+
)
146+
})?;
147+
if declared != expected || declared > MAX_ACTIVATION_LISTENERS {
148+
return Err(io::Error::new(
149+
io::ErrorKind::InvalidInput,
150+
format!(
151+
"expected {expected} inherited listener(s), but LISTEN_FDS declares {declared}"
152+
),
153+
));
154+
}
155+
Ok(())
87156
}
88157

89158
#[cfg(not(target_os = "linux"))]
@@ -109,6 +178,32 @@ mod tests {
109178
assert_eq!(listener.local_addr().unwrap(), expected);
110179
}
111180

181+
#[test]
182+
fn rejects_non_tcp_stream_protocol() {
183+
let error = validate_tcp_listener_properties(
184+
2,
185+
rustix::net::SocketType::STREAM,
186+
Some(rustix::net::ipproto::UDP),
187+
true,
188+
)
189+
.unwrap_err();
190+
191+
assert!(error.to_string().contains("index 2 does not use TCP"));
192+
}
193+
194+
#[test]
195+
fn bounds_declared_listener_count_before_descriptor_receipt() {
196+
assert!(validate_declared_count(1, Some("1")).is_ok());
197+
assert!(validate_declared_count(0, Some("0")).is_err());
198+
assert!(validate_declared_count(129, Some("129")).is_err());
199+
assert!(validate_declared_count(1, None).is_err());
200+
assert!(validate_declared_count(1, Some("invalid")).is_err());
201+
assert!(validate_declared_count(1, Some("2")).is_err());
202+
assert!(
203+
validate_declared_count(1, Some("184467440737095516160000000000000000000")).is_err()
204+
);
205+
}
206+
112207
#[test]
113208
fn rejects_owned_connected_tcp_stream() {
114209
let listener = TcpListener::bind("127.0.0.1:0").unwrap();

docs/zero-downtime-upgrades.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ for activated production listeners.
6565
Descriptor receipt never clears or changes `LISTEN_PID`, `LISTEN_FDS`, or
6666
`LISTEN_FDNAMES`. Mutating process environment storage after Tokio worker
6767
threads exist is not memory-safe on Unix. The focused `fluxheim-systemd` crate
68-
uses non-mutating libsystemd receipt and contains the single audited raw-FD
69-
ownership transfer required to construct owned Rust listeners.
68+
independently bounds the declared listener count before non-mutating libsystemd
69+
receipt, explicitly verifies `SOCK_STREAM`, `SO_PROTOCOL=TCP`, internet-family,
70+
and listening state, and contains the single audited raw-FD ownership transfer
71+
required to construct owned Rust listeners.
7072

7173
Descriptor adoption is process-wide and one-shot. Concurrent or repeated
7274
calls fail before reading FD 3. Fluxheim converts the complete declared set to

release-notes/RELEASE_NOTES_1.7.12.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ trailers are not part of this release.
140140
- Bound storage-bin manifests to 4 KiB and use no-follow, nonblocking regular
141141
file reads so oversized or special persistent files fail closed at startup.
142142

143+
## Socket-Activation Hardening
144+
145+
- Bound `LISTEN_FDS` to 1 through 128 inside the focused systemd adoption crate
146+
before libsystemd can allocate descriptor storage, independently preserving
147+
the root runtime's existing launch-environment validation.
148+
- Require every inherited internet stream listener to report
149+
`SO_PROTOCOL=TCP`, in addition to the existing socket-family, stream-type,
150+
listening-state, planned-address, and one-shot ownership checks.
151+
143152
## Reproducible FIPS-Backend Evidence
144153

145154
- Add separately pinned OpenSSL-FIPS and rustls/AWS-LC-FIPS proof
@@ -180,3 +189,6 @@ platform, configuration, key handling, and required compliance evidence.
180189
slash encodings, encoded Unicode controls, and valid encoded Unicode.
181190
- Static-web tests cover missing children below valid and broken symlinked
182191
parents plus hidden non-UTF-8 Unix filenames.
192+
- Systemd unit and process-level smoke coverage verifies explicit TCP protocol
193+
admission, bounded declarations before receipt, one-shot ownership,
194+
complete-set closure on validation failure, and normal listener adoption.

scripts/smoke_systemd_socket_activation.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ os.execve(binary, [binary, mode], environment)
103103
PY
104104
done
105105

106+
python3 - "$ownership_probe" <<'PY'
107+
import os
108+
import sys
109+
110+
binary = sys.argv[1]
111+
environment = os.environ.copy()
112+
environment["LISTEN_FDS"] = "129"
113+
environment["LISTEN_PID"] = str(os.getpid())
114+
os.execve(binary, [binary, "oversized-declaration"], environment)
115+
PY
116+
106117
python3 - "$ownership_probe" <<'PY'
107118
import os
108119
import socket

scripts/validate-systemd-activation-policy.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ if ! rg -q 'receive_descriptors\(false\)' "$boundary"; then
88
echo "systemd activation policy: descriptor receipt must preserve LISTEN_*" >&2
99
exit 1
1010
fi
11+
preflight_line=$(rg -n 'validate_declared_count\(expected, activation_listener_declaration\(\)\?\.as_deref\(\)\)\?;' "$boundary" | cut -d: -f1 || true)
12+
receipt_line=$(rg -n 'receive_descriptors\(false\)' "$boundary" | cut -d: -f1 || true)
13+
if [ -z "$preflight_line" ] || [ -z "$receipt_line" ] || [ "$preflight_line" -ge "$receipt_line" ]; then
14+
echo "systemd activation policy: LISTEN_FDS must be bounded before descriptor receipt" >&2
15+
exit 1
16+
fi
17+
if ! rg -q 'socket_protocol\(&owned\)\?' "$boundary" \
18+
|| ! rg -q 'protocol != Some\(rustix::net::ipproto::TCP\)' "$boundary"; then
19+
echo "systemd activation policy: inherited stream descriptors must explicitly use TCP" >&2
20+
exit 1
21+
fi
1122
if ! rg -q 'static ACTIVATION_CONSUMED: AtomicBool' "$boundary" \
1223
|| ! rg -q '\.compare_exchange\(false, true, Ordering::AcqRel, Ordering::Acquire\)' "$boundary"; then
1324
echo "systemd activation policy: descriptor ownership must be process-wide and one-shot" >&2

0 commit comments

Comments
 (0)