Skip to content

Commit b9c5193

Browse files
committed
Isolate PHP spool reader offsets
1 parent d7746b0 commit b9c5193

6 files changed

Lines changed: 175 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ behavior when the change improves security or project direction.
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.
3131
- Bound complete FastCGI request/response operations with one deadline, retain
32-
anonymous request-body spool descriptors, and fix the managed child `PATH`.
32+
anonymous request-body spool descriptors with independent positional reader
33+
offsets, and fix the managed child `PATH`.
3334
- Bind managed PHP-FPM spawn to a trusted executable descriptor and terminate
3435
its complete dedicated process group during shutdown and watchdog recovery.
3536

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

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
use std::io;
22
use std::path::{Path, PathBuf};
3+
use std::pin::Pin;
34
use std::sync::Arc;
45
use std::sync::atomic::{AtomicUsize, Ordering};
6+
use std::task::{Context, Poll};
57

68
use zeroize::Zeroizing;
79

810
static PHP_REQUEST_BODY_SPOOL_COUNTER: AtomicUsize = AtomicUsize::new(0);
11+
const PHP_SPOOL_POSITIONAL_READ_BYTES: usize = 64 * 1024;
12+
type PhpSpoolReadTask = tokio::task::JoinHandle<io::Result<(Vec<u8>, usize)>>;
913

1014
pub struct PhpRequestBody {
1115
inner: Arc<PhpRequestBodyInner>,
@@ -18,7 +22,15 @@ enum PhpRequestBodyInner {
1822
}
1923

2024
struct PhpRequestBodySpool {
21-
file: std::fs::File,
25+
file: Arc<std::fs::File>,
26+
}
27+
28+
struct PhpSpoolReader {
29+
file: Arc<std::fs::File>,
30+
offset: u64,
31+
pending: Option<PhpSpoolReadTask>,
32+
ready: Vec<u8>,
33+
ready_start: usize,
2234
}
2335

2436
impl PhpRequestBody {
@@ -37,7 +49,9 @@ impl PhpRequestBody {
3749
let file = file.into_std().await;
3850
Ok(Self {
3951
len,
40-
inner: Arc::new(PhpRequestBodyInner::Spool(PhpRequestBodySpool { file })),
52+
inner: Arc::new(PhpRequestBodyInner::Spool(PhpRequestBodySpool {
53+
file: Arc::new(file),
54+
})),
4155
})
4256
}
4357

@@ -56,16 +70,89 @@ impl PhpRequestBody {
5670
PhpRequestBodyInner::Memory(body) => {
5771
Ok(Box::new(fastcgi_client::io::Cursor::new(body.clone())))
5872
}
59-
PhpRequestBodyInner::Spool(spool) => {
60-
use std::io::{Seek as _, SeekFrom};
61-
62-
let mut file = spool.file.try_clone()?;
63-
file.seek(SeekFrom::Start(0))?;
64-
let file = tokio::fs::File::from_std(file);
65-
Ok(Box::new(
66-
fastcgi_client::io::TokioAsyncReadCompatExt::compat(file),
67-
))
73+
PhpRequestBodyInner::Spool(spool) => Ok(Box::new(PhpSpoolReader {
74+
file: Arc::clone(&spool.file),
75+
offset: 0,
76+
pending: None,
77+
ready: Vec::new(),
78+
ready_start: 0,
79+
})),
80+
}
81+
}
82+
}
83+
84+
impl fastcgi_client::io::AsyncRead for PhpSpoolReader {
85+
fn poll_read(
86+
self: Pin<&mut Self>,
87+
context: &mut Context<'_>,
88+
buffer: &mut [u8],
89+
) -> Poll<io::Result<usize>> {
90+
use std::future::Future as _;
91+
92+
let this = self.get_mut();
93+
if buffer.is_empty() {
94+
return Poll::Ready(Ok(0));
95+
}
96+
loop {
97+
if this.ready_start < this.ready.len() {
98+
let available = &this.ready[this.ready_start..];
99+
let copied = available.len().min(buffer.len());
100+
buffer[..copied].copy_from_slice(&available[..copied]);
101+
this.ready_start += copied;
102+
if this.ready_start == this.ready.len() {
103+
this.ready.clear();
104+
this.ready_start = 0;
105+
}
106+
return Poll::Ready(Ok(copied));
107+
}
108+
if let Some(pending) = this.pending.as_mut() {
109+
let result = match Pin::new(pending).poll(context) {
110+
Poll::Pending => return Poll::Pending,
111+
Poll::Ready(result) => result,
112+
};
113+
this.pending = None;
114+
let (bytes, read) = result.map_err(|error| {
115+
io::Error::other(format!("PHP spool read task failed: {error}"))
116+
})??;
117+
if read > bytes.len() {
118+
return Poll::Ready(Err(io::Error::new(
119+
io::ErrorKind::InvalidData,
120+
"PHP spool positional read returned an invalid length",
121+
)));
122+
}
123+
let read_len = read;
124+
let Ok(read) = u64::try_from(read_len) else {
125+
return Poll::Ready(Err(io::Error::new(
126+
io::ErrorKind::InvalidData,
127+
"PHP spool positional read length is not representable",
128+
)));
129+
};
130+
let Some(next_offset) = this.offset.checked_add(read) else {
131+
return Poll::Ready(Err(io::Error::new(
132+
io::ErrorKind::InvalidData,
133+
"PHP spool read offset overflow",
134+
)));
135+
};
136+
this.offset = next_offset;
137+
this.ready = bytes;
138+
this.ready.truncate(read_len);
139+
this.ready_start = 0;
140+
if read_len == 0 {
141+
return Poll::Ready(Ok(0));
142+
}
143+
continue;
68144
}
145+
146+
let file = Arc::clone(&this.file);
147+
let offset = this.offset;
148+
let read_len = buffer.len().min(PHP_SPOOL_POSITIONAL_READ_BYTES);
149+
this.pending = Some(tokio::task::spawn_blocking(move || {
150+
use std::os::unix::fs::FileExt as _;
151+
152+
let mut bytes = vec![0_u8; read_len];
153+
let read = file.read_at(&mut bytes, offset)?;
154+
Ok((bytes, read))
155+
}));
69156
}
70157
}
71158
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,69 @@ fn php_request_body_spool_replays_and_cleans_up_file() {
8585
std::fs::remove_dir(&spool_dir).expect("remove spool dir");
8686
}
8787

88+
#[test]
89+
fn php_request_body_spool_readers_keep_independent_offsets() {
90+
let spool_dir = fluxheim_common::test_support::unique_temp_path("php-fpm-spool-offsets");
91+
std::fs::create_dir_all(&spool_dir).expect("spool dir");
92+
let runtime = tokio::runtime::Builder::new_current_thread()
93+
.enable_io()
94+
.build()
95+
.expect("test runtime");
96+
let expected = (0..(PHP_TEST_SPOOL_BYTES + 17))
97+
.map(|index| (index % 251) as u8)
98+
.collect::<Vec<_>>();
99+
let mut file = runtime
100+
.block_on(create_php_request_body_spool_file(&spool_dir))
101+
.expect("create spool file");
102+
runtime.block_on(async {
103+
use tokio::io::AsyncWriteExt as _;
104+
105+
file.write_all(&expected).await.expect("write spool");
106+
file.flush().await.expect("flush spool");
107+
});
108+
let body = runtime
109+
.block_on(PhpRequestBody::spooled(file, expected.len()))
110+
.expect("retain anonymous spool file");
111+
let mut first = runtime.block_on(body.reader()).expect("first reader");
112+
let mut second = runtime.block_on(body.reader()).expect("second reader");
113+
let mut first_prefix = vec![0_u8; 1_003];
114+
let mut second_prefix = vec![0_u8; 70_001];
115+
let mut first_rest = Vec::new();
116+
let mut second_rest = Vec::new();
117+
118+
runtime.block_on(async {
119+
use fastcgi_client::io::AsyncReadExt as _;
120+
121+
first
122+
.read_exact(&mut first_prefix)
123+
.await
124+
.expect("first prefix");
125+
second
126+
.read_exact(&mut second_prefix)
127+
.await
128+
.expect("second prefix");
129+
first
130+
.read_to_end(&mut first_rest)
131+
.await
132+
.expect("first remainder");
133+
second
134+
.read_to_end(&mut second_rest)
135+
.await
136+
.expect("second remainder");
137+
});
138+
139+
first_prefix.extend(first_rest);
140+
second_prefix.extend(second_rest);
141+
assert_eq!(first_prefix, expected);
142+
assert_eq!(second_prefix, expected);
143+
drop(first);
144+
drop(second);
145+
drop(body);
146+
std::fs::remove_dir(&spool_dir).expect("remove spool dir");
147+
}
148+
149+
const PHP_TEST_SPOOL_BYTES: usize = 128 * 1024;
150+
88151
#[test]
89152
fn php_fpm_stream_chunk_limit_counts_stdout_and_stderr() {
90153
let mut total = 0;

docs/config-reference.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3507,8 +3507,10 @@ FastCGI and lets retries replay the same upload without cloning a large memory
35073507
buffer; both spool settings must be configured together. On Unix, the securely
35083508
created spool entry is unlinked immediately and Fluxheim retains its open file
35093509
descriptor for request and retry readers, preventing a crash from leaving a
3510-
named plaintext upload behind. This does not guarantee raw-media erasure; use
3511-
encrypted storage or tmpfs when storage remanence is in scope. When
3510+
named plaintext upload behind. Overlapping readers use independent logical
3511+
offsets with bounded positional disk reads, so one reader cannot reset or
3512+
consume another reader's body position. This does not guarantee raw-media
3513+
erasure; use encrypted storage or tmpfs when storage remanence is in scope. When
35123514
`php.max_request_body_bytes` is set on the same PHP action, the spool threshold
35133515
must be lower than that body limit. Existing spool paths must be directories,
35143516
and existing directories must not be group/world writable. Fluxheim rechecks

docs/php-runtime-support.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,11 @@ for single-user/rootless deployments.
334334
writable, and runtime spool creation rechecks permissions before writing
335335
upload bodies. On Unix, Fluxheim unlinks each securely created spool entry
336336
immediately and retains only the open descriptor, so crashes do not leave a
337-
named plaintext request body behind. Use encrypted storage or tmpfs when raw
338-
storage remanence is in scope.
337+
named plaintext request body behind. Every request or retry reader maintains
338+
an independent logical offset and uses bounded positional reads through the
339+
blocking-I/O pool, so overlapping readers cannot reset or consume each
340+
other's stream position. Use encrypted storage or tmpfs when raw storage
341+
remanence is in scope.
339342
- Custom FastCGI params in config. Implemented as `[vhosts.php.params]` and
340343
`[vhosts.routes.php.params]` with protected core CGI params.
341344
- Path mapping for separate Fluxheim/php-fpm container filesystem roots.

release-notes/RELEASE_NOTES_1.7.8.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ general-purpose WASI application hosting.
4848
- Run each managed PHP-FPM pool in a dedicated process group and terminate the
4949
complete group on shutdown, failed status checks, and watchdog restarts.
5050
- Unlink request-body spool files immediately after secure creation while
51-
retaining a rewindable descriptor for retry replay.
51+
retaining a descriptor for retry replay. Give every reader an independent
52+
logical offset backed by bounded positional reads so overlapping readers
53+
cannot corrupt each other's request body stream.
5254
- Replace inherited managed PHP-FPM `PATH` handling with a fixed allowlisted
5355
search path after clearing the child environment.
5456

0 commit comments

Comments
 (0)