Skip to content

Commit d7746b0

Browse files
committed
Harden managed PHP-FPM execution
1 parent b8c6450 commit d7746b0

14 files changed

Lines changed: 501 additions & 146 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ behavior when the change improves security or project direction.
2828
configurable admission pool and a fixed 32-slot blocking-work class.
2929
- Document that the opt-in clock capability exposes full host clock resolution
3030
and is unsuitable for untrusted plugins colocated with secret-dependent work.
31+
- Bound complete FastCGI request/response operations with one deadline, retain
32+
anonymous request-body spool descriptors, and fix the managed child `PATH`.
33+
- Bind managed PHP-FPM spawn to a trusted executable descriptor and terminate
34+
its complete dedicated process group during shutdown and watchdog recovery.
3135

3236
## 1.7.7 - 2026-07-10
3337

crates/fluxheim-php-fpm/src/managed_config.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@ pub fn managed_php_fpm_restart_backoff_secs(restart_failures: usize) -> u64 {
1111
}
1212

1313
pub fn managed_php_fpm_path_env_from(value: Option<String>) -> String {
14-
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
14+
const MANAGED_PHP_FPM_PATH: &str =
15+
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
1516

16-
value
17-
.filter(|value| {
18-
!value.is_empty() && value.bytes().all(|byte| !matches!(byte, 0..=31 | 127))
19-
})
20-
.unwrap_or_else(|| DEFAULT_PATH.to_owned())
17+
let _ = value;
18+
MANAGED_PHP_FPM_PATH.to_owned()
2119
}
2220

2321
pub fn managed_php_fpm_instance_name(metric_pool: &str) -> io::Result<String> {

crates/fluxheim-php-fpm/src/managed_process_lifecycle.rs

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ use std::time::{Duration, Instant};
77
use crate::managed_config::managed_php_fpm_restart_backoff_secs;
88
use crate::managed_process::ManagedPhpFpmSpawnPlan;
99
use crate::managed_spawn::{
10-
ensure_managed_php_fpm_binary_spawn_safe, managed_php_fpm_path_env,
11-
wait_for_managed_php_fpm_socket,
10+
managed_php_fpm_path_env, open_managed_php_fpm_executable, wait_for_managed_php_fpm_socket,
1211
};
1312

1413
const MANAGED_PHP_FPM_STABLE_RESTART_SECS: u64 = 30;
@@ -47,11 +46,11 @@ pub(super) fn spawn_managed_php_fpm_cleanup(
4746
Ok(mut guard) => guard.take(),
4847
Err(poisoned) => poisoned.into_inner().take(),
4948
};
50-
if let Some(mut child) = child {
49+
if let Some(child) = child {
5150
// Drop can run on a Tokio worker after the last request releases an
5251
// old runtime snapshot. If cleanup-thread creation fails, do not
5352
// block that worker on Child::wait().
54-
let _ = child.kill();
53+
signal_managed_php_fpm_group(&child, rustix::process::Signal::KILL);
5554
}
5655
cleanup_managed_php_fpm_files(&socket, &config_path, &pid_path);
5756
}
@@ -66,63 +65,73 @@ pub(super) fn cleanup_managed_php_fpm_files(socket: &Path, config_path: &Path, p
6665

6766
pub(super) fn terminate_managed_php_fpm_child(child: &mut std::process::Child) {
6867
match child.try_wait() {
69-
Ok(Some(_)) => return,
68+
Ok(Some(_)) => {
69+
signal_managed_php_fpm_group(child, rustix::process::Signal::KILL);
70+
return;
71+
}
7072
Ok(None) => {}
7173
Err(_) => {
72-
let _ = child.kill();
74+
signal_managed_php_fpm_group(child, rustix::process::Signal::KILL);
7375
let _ = child.wait();
7476
return;
7577
}
7678
}
7779

78-
let _ = rustix::process::kill_process(
79-
rustix::process::Pid::from_child(child),
80-
rustix::process::Signal::TERM,
81-
);
80+
signal_managed_php_fpm_group(child, rustix::process::Signal::TERM);
8281
let deadline = Instant::now() + Duration::from_secs(5);
8382
loop {
8483
match child.try_wait() {
85-
Ok(Some(_)) => return,
84+
Ok(Some(_)) => {
85+
signal_managed_php_fpm_group(child, rustix::process::Signal::KILL);
86+
return;
87+
}
8688
Ok(None) if Instant::now() < deadline => {
8789
std::thread::sleep(Duration::from_millis(100));
8890
}
8991
_ => {
90-
let _ = child.kill();
92+
signal_managed_php_fpm_group(child, rustix::process::Signal::KILL);
9193
let _ = child.wait();
9294
return;
9395
}
9496
}
9597
}
9698
}
9799

100+
fn signal_managed_php_fpm_group(child: &std::process::Child, signal: rustix::process::Signal) {
101+
let _ = rustix::process::kill_process_group(rustix::process::Pid::from_child(child), signal);
102+
}
103+
98104
pub(super) fn spawn_managed_php_fpm_child(
99105
plan: &ManagedPhpFpmSpawnPlan,
100106
shutdown: Option<&AtomicBool>,
101107
) -> io::Result<(std::process::Child, Instant)> {
102-
ensure_managed_php_fpm_binary_spawn_safe(&plan.scope, &plan.binary)?;
108+
use std::os::unix::process::CommandExt as _;
109+
110+
let executable = open_managed_php_fpm_executable(&plan.scope, &plan.binary)?;
103111
let _ = std::fs::remove_file(&plan.socket);
104112
let _ = std::fs::remove_file(&plan.pid_path);
105113

106-
let mut child = std::process::Command::new(&plan.binary)
114+
let mut command = executable.command();
115+
command
116+
.process_group(0)
107117
.arg("-F")
108118
.arg("-y")
109119
.arg(&plan.config_path)
110120
.env_clear()
111121
.env("PATH", managed_php_fpm_path_env())
112122
.stdin(std::process::Stdio::null())
113123
.stdout(std::process::Stdio::null())
114-
.stderr(std::process::Stdio::null())
115-
.spawn()
116-
.map_err(|error| {
117-
io::Error::new(
118-
error.kind(),
119-
format!(
120-
"{}: failed to start managed php-fpm binary {}: {error}",
121-
plan.scope,
122-
plan.binary.display()
123-
),
124-
)
125-
})?;
124+
.stderr(std::process::Stdio::null());
125+
let mut child = command.spawn().map_err(|error| {
126+
io::Error::new(
127+
error.kind(),
128+
format!(
129+
"{}: failed to start managed php-fpm binary {}: {error}",
130+
plan.scope,
131+
plan.binary.display()
132+
),
133+
)
134+
})?;
126135

127136
match wait_for_managed_php_fpm_socket(
128137
&mut child,
@@ -185,12 +194,16 @@ fn run_managed_php_fpm_watchdog(
185194
};
186195
match guard.as_mut().map(std::process::Child::try_wait) {
187196
Some(Ok(Some(status))) => {
188-
*guard = None;
197+
if let Some(exited_child) = guard.take() {
198+
signal_managed_php_fpm_group(&exited_child, rustix::process::Signal::KILL);
199+
}
189200
Some(format!("exited with status {status}"))
190201
}
191202
Some(Ok(None)) => None,
192203
Some(Err(error)) => {
193-
*guard = None;
204+
if let Some(mut failed_child) = guard.take() {
205+
terminate_managed_php_fpm_child(&mut failed_child);
206+
}
194207
Some(format!("status check failed: {error}"))
195208
}
196209
None => Some("missing child process handle".to_owned()),
@@ -274,3 +287,54 @@ fn managed_php_fpm_sleep_until_restart(shutdown: &AtomicBool, restart_failures:
274287
std::thread::sleep((deadline - now).min(Duration::from_millis(100)));
275288
}
276289
}
290+
291+
#[cfg(all(test, target_os = "linux"))]
292+
mod tests {
293+
use super::terminate_managed_php_fpm_child;
294+
use std::os::unix::process::CommandExt as _;
295+
use std::time::{Duration, Instant};
296+
297+
#[test]
298+
fn forced_cleanup_terminates_managed_process_group() {
299+
let pid_path = fluxheim_common::test_support::unique_temp_path("php-fpm-worker-pid");
300+
let script = format!("sleep 30 & echo $! > '{}'; wait", pid_path.display());
301+
let mut command = std::process::Command::new("/bin/sh");
302+
command
303+
.process_group(0)
304+
.arg("-c")
305+
.arg(script)
306+
.stdin(std::process::Stdio::null())
307+
.stdout(std::process::Stdio::null())
308+
.stderr(std::process::Stdio::null());
309+
let mut child = command.spawn().expect("spawn process group");
310+
let deadline = Instant::now() + Duration::from_secs(2);
311+
while !pid_path.exists() && Instant::now() < deadline {
312+
std::thread::sleep(Duration::from_millis(10));
313+
}
314+
let worker_pid = std::fs::read_to_string(&pid_path)
315+
.expect("worker pid file")
316+
.trim()
317+
.parse::<u32>()
318+
.expect("worker pid");
319+
320+
terminate_managed_php_fpm_child(&mut child);
321+
322+
assert!(child.try_wait().expect("master status").is_some());
323+
let worker_proc = std::path::PathBuf::from(format!("/proc/{worker_pid}/stat"));
324+
let deadline = Instant::now() + Duration::from_secs(2);
325+
loop {
326+
match std::fs::read_to_string(&worker_proc) {
327+
Ok(stat) if !stat.contains(") Z ") && Instant::now() < deadline => {
328+
std::thread::sleep(Duration::from_millis(10));
329+
}
330+
Ok(stat) => {
331+
assert!(stat.contains(") Z "), "worker remained alive: {stat}");
332+
break;
333+
}
334+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
335+
Err(error) => panic!("failed to inspect worker process: {error}"),
336+
}
337+
}
338+
let _ = std::fs::remove_file(pid_path);
339+
}
340+
}

crates/fluxheim-php-fpm/src/managed_spawn.rs

Lines changed: 97 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,20 @@ use crate::request_body::{
88
create_php_request_body_spool_dir_sync, ensure_php_request_body_spool_dir,
99
};
1010

11+
pub(super) struct ManagedPhpFpmExecutable {
12+
file: std::fs::File,
13+
}
14+
1115
pub fn ensure_managed_php_fpm_binary_spawn_safe(scope: &str, binary: &Path) -> io::Result<()> {
16+
open_managed_php_fpm_executable(scope, binary).map(|_| ())
17+
}
18+
19+
pub(super) fn open_managed_php_fpm_executable(
20+
scope: &str,
21+
binary: &Path,
22+
) -> io::Result<ManagedPhpFpmExecutable> {
23+
use rustix::fs::{Mode, OFlags};
24+
1225
if binary.as_os_str().is_empty() || !binary.is_absolute() {
1326
return Err(io::Error::new(
1427
io::ErrorKind::InvalidInput,
@@ -39,34 +52,61 @@ pub fn ensure_managed_php_fpm_binary_spawn_safe(scope: &str, binary: &Path) -> i
3952
),
4053
));
4154
}
42-
if existing_php_fpm_parent_has_insecure_write_permissions(binary)? {
55+
let file = rustix::fs::openat(
56+
rustix::fs::CWD,
57+
binary,
58+
OFlags::RDONLY | OFlags::NOFOLLOW,
59+
Mode::empty(),
60+
)
61+
.map_err(|error| {
62+
io::Error::new(
63+
io::Error::from(error).kind(),
64+
format!(
65+
"{scope}: failed to open managed php-fpm binary {} safely: {error}",
66+
binary.display()
67+
),
68+
)
69+
})?;
70+
let stat = rustix::fs::fstat(&file).map_err(io::Error::from)?;
71+
let effective_uid = rustix::process::geteuid().as_raw();
72+
if !rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file()
73+
|| (stat.st_uid != 0 && stat.st_uid != effective_uid)
74+
|| stat.st_mode & 0o022 != 0
75+
|| stat.st_mode & 0o111 == 0
76+
{
4377
return Err(io::Error::new(
4478
io::ErrorKind::PermissionDenied,
4579
format!(
46-
"{scope}: managed php-fpm binary {} is below a group/world-writable parent",
80+
"{scope}: managed php-fpm binary {} has an untrusted owner or mode",
4781
binary.display()
4882
),
4983
));
5084
}
51-
let metadata = std::fs::symlink_metadata(binary).map_err(|error| {
52-
io::Error::new(
53-
error.kind(),
54-
format!(
55-
"{scope}: failed to inspect managed php-fpm binary {} before spawn: {error}",
56-
binary.display()
57-
),
58-
)
59-
})?;
60-
if !metadata.is_file() {
85+
let parent = binary.parent().unwrap_or_else(|| Path::new("/"));
86+
if fluxheim_config::fs_trust::existing_path_or_parent_has_insecure_write_permissions(parent)? {
6187
return Err(io::Error::new(
62-
io::ErrorKind::InvalidInput,
88+
io::ErrorKind::PermissionDenied,
6389
format!(
64-
"{scope}: managed php-fpm binary {} must point directly to a regular file",
90+
"{scope}: managed php-fpm binary {} is below an untrusted or group/world-writable ancestor",
6591
binary.display()
6692
),
6793
));
6894
}
69-
Ok(())
95+
Ok(ManagedPhpFpmExecutable {
96+
file: std::fs::File::from(file),
97+
})
98+
}
99+
100+
impl ManagedPhpFpmExecutable {
101+
pub(super) fn command(&self) -> std::process::Command {
102+
use std::os::fd::AsRawFd as _;
103+
104+
#[cfg(target_os = "linux")]
105+
let path = format!("/proc/self/fd/{}", self.file.as_raw_fd());
106+
#[cfg(not(target_os = "linux"))]
107+
let path = format!("/dev/fd/{}", self.file.as_raw_fd());
108+
std::process::Command::new(path)
109+
}
70110
}
71111

72112
fn existing_php_fpm_path_prefix_contains_symlink(path: &Path) -> io::Result<bool> {
@@ -83,30 +123,8 @@ fn existing_php_fpm_path_prefix_contains_symlink(path: &Path) -> io::Result<bool
83123
Ok(false)
84124
}
85125

86-
fn existing_php_fpm_parent_has_insecure_write_permissions(path: &Path) -> io::Result<bool> {
87-
use std::os::unix::fs::PermissionsExt;
88-
89-
let mut current = path
90-
.parent()
91-
.filter(|parent| !parent.as_os_str().is_empty())
92-
.unwrap_or_else(|| Path::new("."))
93-
.to_path_buf();
94-
95-
loop {
96-
match std::fs::metadata(&current) {
97-
Ok(metadata) => return Ok(metadata.permissions().mode() & 0o022 != 0),
98-
Err(error) if error.kind() == io::ErrorKind::NotFound => {
99-
if !current.pop() {
100-
return Ok(false);
101-
}
102-
}
103-
Err(error) => return Err(error),
104-
}
105-
}
106-
}
107-
108126
pub(super) fn managed_php_fpm_path_env() -> String {
109-
managed_php_fpm_path_env_from(std::env::var("PATH").ok())
127+
managed_php_fpm_path_env_from(None)
110128
}
111129

112130
pub(super) fn write_managed_php_fpm_config_file(path: &Path, contents: &[u8]) -> io::Result<()> {
@@ -183,3 +201,44 @@ pub(super) fn wait_for_managed_php_fpm_socket(
183201
std::thread::sleep(Duration::from_millis(50));
184202
}
185203
}
204+
205+
#[cfg(all(test, target_os = "linux"))]
206+
mod tests {
207+
use super::ManagedPhpFpmExecutable;
208+
use std::os::unix::fs::MetadataExt as _;
209+
use std::os::unix::fs::PermissionsExt as _;
210+
211+
#[test]
212+
fn retained_executable_descriptor_survives_path_replacement() {
213+
use rustix::fs::{Mode, OFlags};
214+
215+
let root = tempfile::TempDir::new().expect("temp dir");
216+
let binary = root.path().join("php-fpm");
217+
let moved = root.path().join("php-fpm.original");
218+
std::fs::copy("/usr/bin/true", &binary).expect("copy original executable");
219+
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700))
220+
.expect("set executable mode");
221+
let file = rustix::fs::openat(
222+
rustix::fs::CWD,
223+
&binary,
224+
OFlags::RDONLY | OFlags::NOFOLLOW,
225+
Mode::empty(),
226+
)
227+
.expect("open executable descriptor");
228+
let executable = ManagedPhpFpmExecutable {
229+
file: std::fs::File::from(file),
230+
};
231+
std::fs::rename(&binary, &moved).expect("move original executable");
232+
std::fs::copy("/usr/bin/false", &binary).expect("copy replacement executable");
233+
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700))
234+
.expect("set replacement mode");
235+
236+
let retained_metadata = executable.file.metadata().expect("retained metadata");
237+
let replacement_metadata = std::fs::metadata(&binary).expect("replacement metadata");
238+
let command = executable.command();
239+
let command_path = command.get_program().to_string_lossy();
240+
241+
assert_ne!(retained_metadata.ino(), replacement_metadata.ino());
242+
assert!(command_path.starts_with("/proc/self/fd/"));
243+
}
244+
}

0 commit comments

Comments
 (0)