Skip to content

Commit 65fa843

Browse files
committed
Sanitize PHP spool read buffers
1 parent b9c5193 commit 65fa843

9 files changed

Lines changed: 101 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ behavior when the change improves security or project direction.
3030
and is unsuitable for untrusted plugins colocated with secret-dependent work.
3131
- Bound complete FastCGI request/response operations with one deadline, retain
3232
anonymous request-body spool descriptors with independent positional reader
33-
offsets, and fix the managed child `PATH`.
33+
offsets, protect memory and spool-read buffers with
34+
`sanitization::SecretVec`, and fix the managed child `PATH`.
3435
- Bind managed PHP-FPM spawn to a trusted executable descriptor and terminate
3536
its complete dedicated process group during shutdown and watchdog recovery.
3637

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fluxheim-php-fpm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ getrandom = "0.4.3"
1515
log = "0.4.33"
1616
percent-encoding = "2.3.2"
1717
rustix = { version = "1.1.4", features = ["fs", "net", "process"] }
18+
sanitization = { version = "1.2.4", features = ["alloc"] }
1819
tokio = { version = "1.52.3", default-features = false, features = ["fs", "io-util", "net", "rt", "time"] }
19-
zeroize = "1.9.0"
2020

2121
[dev-dependencies]
2222
fluxheim-common = { path = "../fluxheim-common", features = ["test-support"] }

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

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ use std::sync::Arc;
55
use std::sync::atomic::{AtomicUsize, Ordering};
66
use std::task::{Context, Poll};
77

8-
use zeroize::Zeroizing;
8+
use sanitization::SecretVec;
99

1010
static PHP_REQUEST_BODY_SPOOL_COUNTER: AtomicUsize = AtomicUsize::new(0);
1111
const PHP_SPOOL_POSITIONAL_READ_BYTES: usize = 64 * 1024;
12-
type PhpSpoolReadTask = tokio::task::JoinHandle<io::Result<(Vec<u8>, usize)>>;
12+
type PhpSpoolReadTask = tokio::task::JoinHandle<io::Result<(SecretVec, usize)>>;
1313

1414
pub struct PhpRequestBody {
1515
inner: Arc<PhpRequestBodyInner>,
1616
len: usize,
1717
}
1818

1919
enum PhpRequestBodyInner {
20-
Memory(Zeroizing<Vec<u8>>),
20+
Memory(Arc<SecretVec>),
2121
Spool(PhpRequestBodySpool),
2222
}
2323

@@ -29,19 +29,25 @@ struct PhpSpoolReader {
2929
file: Arc<std::fs::File>,
3030
offset: u64,
3131
pending: Option<PhpSpoolReadTask>,
32-
ready: Vec<u8>,
32+
ready: SecretVec,
3333
ready_start: usize,
34+
ready_len: usize,
35+
}
36+
37+
struct PhpMemoryReader {
38+
body: Arc<SecretVec>,
39+
offset: usize,
3440
}
3541

3642
impl PhpRequestBody {
3743
pub fn memory(body: Vec<u8>) -> Self {
38-
Self::memory_zeroizing(Zeroizing::new(body))
44+
Self::memory_secret(SecretVec::from_vec(body))
3945
}
4046

41-
pub fn memory_zeroizing(body: Zeroizing<Vec<u8>>) -> Self {
47+
pub fn memory_secret(body: SecretVec) -> Self {
4248
Self {
4349
len: body.len(),
44-
inner: Arc::new(PhpRequestBodyInner::Memory(body)),
50+
inner: Arc::new(PhpRequestBodyInner::Memory(Arc::new(body))),
4551
}
4652
}
4753

@@ -67,20 +73,40 @@ impl PhpRequestBody {
6773
&self,
6874
) -> io::Result<Box<dyn fastcgi_client::io::AsyncRead + Unpin + Send>> {
6975
match self.inner.as_ref() {
70-
PhpRequestBodyInner::Memory(body) => {
71-
Ok(Box::new(fastcgi_client::io::Cursor::new(body.clone())))
72-
}
76+
PhpRequestBodyInner::Memory(body) => Ok(Box::new(PhpMemoryReader {
77+
body: Arc::clone(body),
78+
offset: 0,
79+
})),
7380
PhpRequestBodyInner::Spool(spool) => Ok(Box::new(PhpSpoolReader {
7481
file: Arc::clone(&spool.file),
7582
offset: 0,
7683
pending: None,
77-
ready: Vec::new(),
84+
ready: SecretVec::empty(),
7885
ready_start: 0,
86+
ready_len: 0,
7987
})),
8088
}
8189
}
8290
}
8391

92+
impl fastcgi_client::io::AsyncRead for PhpMemoryReader {
93+
fn poll_read(
94+
self: Pin<&mut Self>,
95+
_context: &mut Context<'_>,
96+
buffer: &mut [u8],
97+
) -> Poll<io::Result<usize>> {
98+
let this = self.get_mut();
99+
let copied = this.body.with_secret(|body| {
100+
let available = body.get(this.offset..).unwrap_or_default();
101+
let copied = available.len().min(buffer.len());
102+
buffer[..copied].copy_from_slice(&available[..copied]);
103+
copied
104+
});
105+
this.offset = this.offset.saturating_add(copied);
106+
Poll::Ready(Ok(copied))
107+
}
108+
}
109+
84110
impl fastcgi_client::io::AsyncRead for PhpSpoolReader {
85111
fn poll_read(
86112
self: Pin<&mut Self>,
@@ -94,14 +120,18 @@ impl fastcgi_client::io::AsyncRead for PhpSpoolReader {
94120
return Poll::Ready(Ok(0));
95121
}
96122
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]);
123+
if this.ready_start < this.ready_len {
124+
let copied = this.ready.with_secret(|ready| {
125+
let available = &ready[this.ready_start..this.ready_len];
126+
let copied = available.len().min(buffer.len());
127+
buffer[..copied].copy_from_slice(&available[..copied]);
128+
copied
129+
});
101130
this.ready_start += copied;
102-
if this.ready_start == this.ready.len() {
103-
this.ready.clear();
131+
if this.ready_start == this.ready_len {
132+
this.ready.clear_secret();
104133
this.ready_start = 0;
134+
this.ready_len = 0;
105135
}
106136
return Poll::Ready(Ok(copied));
107137
}
@@ -135,9 +165,10 @@ impl fastcgi_client::io::AsyncRead for PhpSpoolReader {
135165
};
136166
this.offset = next_offset;
137167
this.ready = bytes;
138-
this.ready.truncate(read_len);
139168
this.ready_start = 0;
169+
this.ready_len = read_len;
140170
if read_len == 0 {
171+
this.ready.clear_secret();
141172
return Poll::Ready(Ok(0));
142173
}
143174
continue;
@@ -149,8 +180,8 @@ impl fastcgi_client::io::AsyncRead for PhpSpoolReader {
149180
this.pending = Some(tokio::task::spawn_blocking(move || {
150181
use std::os::unix::fs::FileExt as _;
151182

152-
let mut bytes = vec![0_u8; read_len];
153-
let read = file.read_at(&mut bytes, offset)?;
183+
let mut bytes = SecretVec::from_fn(read_len, |_| 0);
184+
let read = bytes.with_secret_mut(|bytes| file.read_at(bytes, offset))?;
154185
Ok((bytes, read))
155186
}));
156187
}

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

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,36 @@ fn php_request_body_replays_memory_body() {
1919
.expect("test runtime");
2020
let body = PhpRequestBody::memory(b"body".to_vec());
2121

22-
let mut reader = runtime.block_on(body.reader()).expect("memory reader");
23-
let mut replayed = Vec::new();
24-
runtime
25-
.block_on(fastcgi_client::io::AsyncReadExt::read_to_end(
26-
&mut reader,
27-
&mut replayed,
28-
))
29-
.expect("read memory body");
22+
let mut first = runtime
23+
.block_on(body.reader())
24+
.expect("first memory reader");
25+
let mut second = runtime
26+
.block_on(body.reader())
27+
.expect("second memory reader");
28+
let mut first_prefix = [0_u8; 2];
29+
let mut first_rest = Vec::new();
30+
let mut second_replay = Vec::new();
31+
runtime.block_on(async {
32+
use fastcgi_client::io::AsyncReadExt as _;
33+
34+
first
35+
.read_exact(&mut first_prefix)
36+
.await
37+
.expect("first memory prefix");
38+
second
39+
.read_to_end(&mut second_replay)
40+
.await
41+
.expect("second memory replay");
42+
first
43+
.read_to_end(&mut first_rest)
44+
.await
45+
.expect("first memory remainder");
46+
});
3047

3148
assert_eq!(body.len(), 4);
32-
assert_eq!(replayed, b"body");
49+
assert_eq!(first_prefix, *b"bo");
50+
assert_eq!(first_rest, b"dy");
51+
assert_eq!(second_replay, b"body");
3352
}
3453

3554
#[test]

crates/fluxheim-server/src/native_http1_php.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::time::Duration;
1313
use fluxheim_config::PhpConfig;
1414
use fluxheim_php_fpm::{PhpFpmPool, PhpRequestBody};
1515
use fluxheim_protocol::{Http1RequestTarget, Http1Version, http1_request_target};
16+
use sanitization::SecretVec;
1617

1718
use crate::{NativeHttp1Request, NativeHttp1Response};
1819

@@ -64,13 +65,13 @@ pub(crate) async fn native_php_request_body(
6465
));
6566
}
6667
let Some(threshold) = php.request_body_spool_threshold_bytes else {
67-
return Ok(fluxheim_php_fpm::PhpRequestBody::memory_zeroizing(
68-
request.body.clone(),
68+
return Ok(fluxheim_php_fpm::PhpRequestBody::memory_secret(
69+
SecretVec::from_slice(&request.body),
6970
));
7071
};
7172
if request.body.len() as u64 <= threshold.as_u64() {
72-
return Ok(fluxheim_php_fpm::PhpRequestBody::memory_zeroizing(
73-
request.body.clone(),
73+
return Ok(fluxheim_php_fpm::PhpRequestBody::memory_secret(
74+
SecretVec::from_slice(&request.body),
7475
));
7576
}
7677
let Some(spool_dir) = php.request_body_spool_dir.as_deref() else {

docs/config-reference.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3509,8 +3509,11 @@ 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
35103510
named plaintext upload behind. Overlapping readers use independent logical
35113511
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
3512+
consume another reader's body position. Fluxheim keeps memory bodies and each
3513+
bounded positional-read buffer in `sanitization::SecretVec`, clears consumed
3514+
buffers immediately, and clears their full allocation capacity on cancellation,
3515+
error, or drop. This does not guarantee raw-media erasure; use encrypted storage
3516+
or tmpfs when storage remanence is in scope. When
35143517
`php.max_request_body_bytes` is set on the same PHP action, the spool threshold
35153518
must be lower than that body limit. Existing spool paths must be directories,
35163519
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
@@ -337,8 +337,11 @@ for single-user/rootless deployments.
337337
named plaintext request body behind. Every request or retry reader maintains
338338
an independent logical offset and uses bounded positional reads through the
339339
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.
340+
other's stream position. In-memory request bodies and positional-read buffers
341+
are held in `sanitization::SecretVec`; consumed buffers are cleared
342+
immediately, and cancellation, errors, and drop clear their full allocation
343+
capacity. Use encrypted storage or tmpfs when raw storage remanence is in
344+
scope.
342345
- Custom FastCGI params in config. Implemented as `[vhosts.php.params]` and
343346
`[vhosts.routes.php.params]` with protected core CGI params.
344347
- Path mapping for separate Fluxheim/php-fpm container filesystem roots.

release-notes/RELEASE_NOTES_1.7.8.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ general-purpose WASI application hosting.
5151
retaining a descriptor for retry replay. Give every reader an independent
5252
logical offset backed by bounded positional reads so overlapping readers
5353
cannot corrupt each other's request body stream.
54+
- Hold PHP memory bodies and bounded spool-read buffers in
55+
`sanitization::SecretVec`, clear consumed spool buffers immediately, and
56+
clear full buffer capacity on cancellation, error, or drop.
5457
- Replace inherited managed PHP-FPM `PATH` handling with a fixed allowlisted
5558
search path after clearing the child environment.
5659

0 commit comments

Comments
 (0)