Skip to content

Commit a862cd9

Browse files
committed
Harden native buffering and encrypted cache state
1 parent f1d3aaa commit a862cd9

38 files changed

Lines changed: 1286 additions & 140 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Fluxheim is licensed under the European Union Public Licence 1.2.
7575
| Memory cache || Bounded in-memory cache tier. |
7676
| Disk cache || Filesystem and storage-bin disk backends with ordered eviction, one process-wide persistence worker, pre-initialization advisory lease enforcement, live inspection, fail-closed lookup admission, and coalesced durable index writes. |
7777
| Tiered cache || Memory plus disk storage plans. |
78-
| Encrypted disk cache || Optional local-key and OpenBao transit encryption paths. |
78+
| Encrypted disk cache || Optional local-key and OpenBao transit encryption paths; confidential v2 object/index metadata and durable fail-closed local AES-GCM invocation accounting. |
7979
| Static-file cache || Optional local static-file caching. |
8080
| Range and slice cache || Bounded range caching and fixed-slice composition for large objects. |
8181
| Peer fill || Optional peer-assisted cache fill for cache-edge deployments. |

crates/fluxheim-config/src/config_error_display.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ impl Display for ConfigError {
5353
}
5454
Self::EmptyProcessPath { field } => write!(formatter, "{field} cannot be empty"),
5555
Self::InvalidLimit { field } => write!(formatter, "{field} must be greater than zero"),
56+
Self::InvalidLimitRelationship {
57+
field,
58+
minimum_field,
59+
} => write!(formatter, "{field} must be at least {minimum_field}"),
5660
Self::InvalidAdminListenAddress { address } => write!(
5761
formatter,
5862
"admin.listen must be an ip:port listener address, got {address:?}"

crates/fluxheim-config/src/config_error_kind.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ pub enum ConfigError {
4141
InvalidLimit {
4242
field: &'static str,
4343
},
44+
InvalidLimitRelationship {
45+
field: &'static str,
46+
minimum_field: &'static str,
47+
},
4448
InvalidAdminListenAddress {
4549
address: String,
4650
},

crates/fluxheim-config/src/config_root_validate.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,34 @@ impl Config {
3939
self.udp.validate()?;
4040
self.wasm.validate()?;
4141
self.validate_vhosts()?;
42+
self.validate_request_body_budget()?;
4243
self.validate_wasm_attachments()?;
4344
self.validate_geoip_policy()?;
4445
self.validate_compliance_internal_crypto()?;
4546
Ok(())
4647
}
4748

49+
fn validate_request_body_budget(&self) -> Result<(), ConfigError> {
50+
let budget = self.server.limits.max_buffered_request_body_bytes.as_u64();
51+
let exceeds_budget = self.vhosts.iter().any(|vhost| {
52+
vhost
53+
.max_request_body_bytes
54+
.is_some_and(|limit| limit.as_u64() > budget)
55+
|| vhost.routes.iter().any(|route| {
56+
route
57+
.max_request_body_bytes
58+
.is_some_and(|limit| limit.as_u64() > budget)
59+
})
60+
});
61+
if exceeds_budget {
62+
return Err(ConfigError::InvalidLimitRelationship {
63+
field: "server.limits.max_buffered_request_body_bytes",
64+
minimum_field: "every vhost and route max_request_body_bytes",
65+
});
66+
}
67+
Ok(())
68+
}
69+
4870
fn validate_acme_challenge_runtime(&self) -> Result<(), ConfigError> {
4971
if !self.tls.acme.enabled || self.tls.acme.challenge != AcmeChallenge::TlsAlpn01 {
5072
return Ok(());

crates/fluxheim-config/src/config_server.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ use crate::config::{ByteSize, ConfigError, validate_config_list_len};
77
use crate::config_net::{trusted_proxy_ipv6_prefix_broader_than_32, valid_trusted_proxy};
88
use crate::config_path::{validate_optional_process_path, validate_required_process_path};
99
use crate::config_server_defaults::{
10-
default_https_redirect_status, default_listen, default_max_request_body_bytes,
11-
default_max_request_header_bytes, default_max_request_headers, default_max_uri_bytes,
12-
default_process_certificate_reload_sock, default_process_listener_tasks_per_fd,
13-
default_process_max_retries, default_process_pid_file, default_process_threads,
14-
default_process_upgrade_sock, default_process_upstream_keepalive_pool_size, default_true,
15-
validate_process_optional_duration, validate_process_usize,
10+
default_https_redirect_status, default_listen, default_max_buffered_request_body_bytes,
11+
default_max_request_body_bytes, default_max_request_header_bytes, default_max_request_headers,
12+
default_max_uri_bytes, default_process_certificate_reload_sock,
13+
default_process_listener_tasks_per_fd, default_process_max_retries, default_process_pid_file,
14+
default_process_threads, default_process_upgrade_sock,
15+
default_process_upstream_keepalive_pool_size, default_true, validate_process_optional_duration,
16+
validate_process_usize,
1617
};
1718

1819
pub const MAX_SERVER_LISTENERS: usize = 64;
@@ -370,6 +371,8 @@ pub struct ServerLimitsConfig {
370371
pub max_request_headers: usize,
371372
#[serde(default = "default_max_request_body_bytes")]
372373
pub max_request_body_bytes: ByteSize,
374+
#[serde(default = "default_max_buffered_request_body_bytes")]
375+
pub max_buffered_request_body_bytes: ByteSize,
373376
}
374377

375378
impl Default for ServerLimitsConfig {
@@ -379,6 +382,7 @@ impl Default for ServerLimitsConfig {
379382
max_uri_bytes: default_max_uri_bytes(),
380383
max_request_headers: default_max_request_headers(),
381384
max_request_body_bytes: default_max_request_body_bytes(),
385+
max_buffered_request_body_bytes: default_max_buffered_request_body_bytes(),
382386
}
383387
}
384388
}
@@ -405,6 +409,12 @@ impl ServerLimitsConfig {
405409
field: "server.limits.max_request_body_bytes",
406410
});
407411
}
412+
if self.max_buffered_request_body_bytes.as_u64() < self.max_request_body_bytes.as_u64() {
413+
return Err(ConfigError::InvalidLimitRelationship {
414+
field: "server.limits.max_buffered_request_body_bytes",
415+
minimum_field: "server.limits.max_request_body_bytes",
416+
});
417+
}
408418

409419
Ok(())
410420
}

crates/fluxheim-config/src/config_server_defaults.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub(crate) fn default_max_request_body_bytes() -> ByteSize {
2626
ByteSize::from_bytes(16 * 1024 * 1024)
2727
}
2828

29+
pub(crate) fn default_max_buffered_request_body_bytes() -> ByteSize {
30+
ByteSize::from_bytes(1024 * 1024 * 1024)
31+
}
32+
2933
pub(crate) fn default_process_pid_file() -> PathBuf {
3034
default_process_runtime_path("fluxheim.pid")
3135
}

crates/fluxheim-config/src/config_tests_server.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ fn parses_server_limits() {
1919
max_uri_bytes = 4096
2020
max_request_headers = 32
2121
max_request_body_bytes = "2MiB"
22+
max_buffered_request_body_bytes = "8MiB"
2223
"#,
2324
)
2425
.unwrap();
@@ -36,6 +37,10 @@ fn parses_server_limits() {
3637
config.server.limits.max_request_body_bytes,
3738
ByteSize::from_bytes(2 * 1024 * 1024)
3839
);
40+
assert_eq!(
41+
config.server.limits.max_buffered_request_body_bytes,
42+
ByteSize::from_bytes(8 * 1024 * 1024)
43+
);
3944
assert_eq!(
4045
config.server.trusted_proxies,
4146
["127.0.0.1", "10.0.0.0/8", "2001:db8::/32", "2a06:98c0::/29"]
@@ -125,6 +130,26 @@ fn rejects_zero_server_limits() {
125130
);
126131
}
127132

133+
#[test]
134+
fn rejects_aggregate_body_budget_below_per_request_limit() {
135+
let config: Config = toml::from_str(
136+
r#"
137+
[server.limits]
138+
max_request_body_bytes = "8MiB"
139+
max_buffered_request_body_bytes = "4MiB"
140+
"#,
141+
)
142+
.unwrap();
143+
144+
assert_eq!(
145+
config.validate(),
146+
Err(ConfigError::InvalidLimitRelationship {
147+
field: "server.limits.max_buffered_request_body_bytes",
148+
minimum_field: "server.limits.max_request_body_bytes",
149+
})
150+
);
151+
}
152+
128153
#[test]
129154
fn rejects_empty_listeners() {
130155
let config = Config {

crates/fluxheim-server/src/blocking_work.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const MAX_WASM_BLOCKING_WORK: usize = 96;
1111
#[cfg(any(test, feature = "wasm"))]
1212
const MAX_WASM_PREVIEW_BLOCKING_WORK: usize = 32;
1313
const MAX_DISK_CACHE_BLOCKING_WORK: usize = 32;
14+
const MAX_STATIC_BLOCKING_WORK: usize = 8;
1415
#[cfg(any(test, all(feature = "traffic-mirror", not(feature = "privacy-mode"))))]
1516
const MAX_MIRROR_BLOCKING_WORK: usize = 8;
1617
const MAX_CRITICAL_BLOCKING_WORK: usize = 32;
@@ -26,6 +27,7 @@ pub(crate) enum NativeBlockingWorkClass {
2627
#[cfg(any(test, feature = "wasm"))]
2728
WasmPreview,
2829
DiskCache,
30+
Static,
2931
#[cfg(any(test, all(feature = "traffic-mirror", not(feature = "privacy-mode"))))]
3032
Mirror,
3133
Critical,
@@ -48,6 +50,7 @@ struct NativeRequestBlockingWorkBudgets {
4850
#[cfg(any(test, feature = "wasm"))]
4951
wasm_preview: Arc<Semaphore>,
5052
disk_cache: Arc<Semaphore>,
53+
static_web: Arc<Semaphore>,
5154
#[cfg(any(test, all(feature = "traffic-mirror", not(feature = "privacy-mode"))))]
5255
mirror: Arc<Semaphore>,
5356
critical: Arc<Semaphore>,
@@ -65,6 +68,7 @@ impl NativeRequestBlockingWorkBudgets {
6568
#[cfg(any(test, feature = "wasm"))]
6669
wasm_preview: Arc::new(Semaphore::new(MAX_WASM_PREVIEW_BLOCKING_WORK)),
6770
disk_cache: Arc::new(Semaphore::new(MAX_DISK_CACHE_BLOCKING_WORK)),
71+
static_web: Arc::new(Semaphore::new(MAX_STATIC_BLOCKING_WORK)),
6872
#[cfg(any(test, all(feature = "traffic-mirror", not(feature = "privacy-mode"))))]
6973
mirror: Arc::new(Semaphore::new(MAX_MIRROR_BLOCKING_WORK)),
7074
critical: Arc::new(Semaphore::new(MAX_CRITICAL_BLOCKING_WORK)),
@@ -80,6 +84,7 @@ impl NativeRequestBlockingWorkBudgets {
8084
#[cfg(any(test, feature = "wasm"))]
8185
NativeBlockingWorkClass::WasmPreview => &self.wasm_preview,
8286
NativeBlockingWorkClass::DiskCache => &self.disk_cache,
87+
NativeBlockingWorkClass::Static => &self.static_web,
8388
#[cfg(any(test, all(feature = "traffic-mirror", not(feature = "privacy-mode"))))]
8489
NativeBlockingWorkClass::Mirror => &self.mirror,
8590
NativeBlockingWorkClass::Critical => &self.critical,
@@ -162,6 +167,7 @@ mod tests {
162167
.try_acquire(NativeBlockingWorkClass::DiskCache)
163168
.is_ok()
164169
);
170+
assert!(budgets.try_acquire(NativeBlockingWorkClass::Static).is_ok());
165171
assert!(
166172
budgets
167173
.try_acquire(NativeBlockingWorkClass::Critical)

crates/fluxheim-server/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ mod native_runtime_manifest;
101101
mod plan;
102102
mod process;
103103
mod proxy_protocol;
104+
mod request_body_budget;
105+
mod response_retention;
104106
mod service;
105107
#[cfg(unix)]
106108
mod unix_listener;
@@ -187,6 +189,7 @@ pub use plan::{
187189
};
188190
pub use process::ProcessSpec;
189191
pub use proxy_protocol::{ProxyProtocolPolicy, ProxyProtocolTrustedSource};
192+
pub use request_body_budget::NativeRequestBodyBudget;
190193
pub use service::{AdminOpsSocketPlan, ServiceKind, ServiceSpec};
191194
#[cfg(unix)]
192195
pub use unix_listener::replace_private_unix_listener;

crates/fluxheim-server/src/native_http1.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::sync::Arc;
55
use std::time::Duration;
66

77
use fluxheim_protocol::{
8-
Http1ConnectionDirective, Http1HeadLimits, Http1Header, Http1ParseError,
8+
Http1BodyFraming, Http1ConnectionDirective, Http1HeadLimits, Http1Header, Http1ParseError,
99
parse_http1_request_head,
1010
};
1111
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
@@ -96,6 +96,10 @@ pub trait NativeHttp1Handler: Send + Sync + 'static {
9696
None
9797
}
9898

99+
fn request_body_budget(&self) -> Option<crate::NativeRequestBodyBudget> {
100+
None
101+
}
102+
99103
fn handle<'a>(
100104
&'a self,
101105
request: NativeHttp1Request,
@@ -214,13 +218,53 @@ where
214218
owned_request_from_head(&head, peer_addr, &request_context),
215219
)
216220
};
221+
let request_body_budget = handler.request_body_budget();
217222
let pinned_handler = handler.pin_request_handler();
218223
let request_handler = pinned_handler
219224
.as_deref()
220225
.unwrap_or_else(|| handler.as_ref());
221226
let request_body_timeout = request_handler
222227
.request_body_timeout(&request)
223228
.unwrap_or(policy.request_body_timeout());
229+
let reservation_bytes = match body_framing {
230+
Http1BodyFraming::NoBody => 0,
231+
Http1BodyFraming::ContentLength(length) => {
232+
let Ok(length) = usize::try_from(length) else {
233+
write_response(
234+
&mut stream,
235+
NativeHttp1Response::new(413, "Payload Too Large", b"payload too large\n")
236+
.close_connection(),
237+
true,
238+
)
239+
.await?;
240+
return Ok(());
241+
};
242+
length
243+
}
244+
Http1BodyFraming::Chunked => policy.max_body_bytes(),
245+
};
246+
let _request_body_permit = if let Some(budget) = request_body_budget {
247+
match budget.reserve(reservation_bytes).await {
248+
Ok(permit) => permit,
249+
Err(_) => {
250+
write_response(
251+
&mut stream,
252+
NativeHttp1Response::new(
253+
503,
254+
"Service Unavailable",
255+
b"request body capacity unavailable\n",
256+
)
257+
.with_retry_after_secs(1)
258+
.close_connection(),
259+
true,
260+
)
261+
.await?;
262+
return Ok(());
263+
}
264+
}
265+
} else {
266+
None
267+
};
224268
let body = match read_body(
225269
policy,
226270
request_body_timeout,

0 commit comments

Comments
 (0)