Skip to content

Commit 5317240

Browse files
committed
Harden wasm route decision ordering
1 parent 9ecf8b6 commit 5317240

5 files changed

Lines changed: 161 additions & 44 deletions

File tree

crates/fluxheim-server/src/native_http1_route_proxy_handler.rs

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,6 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
6060
let client_ip = self.access_client_ip(&request);
6161
let tls_identity = request.tls_identity.as_ref();
6262
let geo_context = request.geo_context.as_ref();
63-
#[cfg(feature = "wasm")]
64-
let selected_route = {
65-
match self
66-
.wasm_route_decision(&request, &path, selected_route)
67-
.await
68-
{
69-
NativeWasmRouteResolution::Continue => selected_route,
70-
NativeWasmRouteResolution::Select(route) => Some(route),
71-
NativeWasmRouteResolution::Reject(response) => return response,
72-
}
73-
};
7463
if !self.access.allows(client_ip, tls_identity, geo_context)
7564
|| selected_route
7665
.is_some_and(|route| !route.access.allows(client_ip, tls_identity, geo_context))
@@ -80,22 +69,6 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
8069
return NativeHttp1Response::new(403, "Forbidden", b"forbidden\n")
8170
.close_connection();
8271
}
83-
#[cfg(feature = "wasm")]
84-
if let Some(response) =
85-
wasm_access_rejection(self, decoded_policy_route.or(selected_route)).await
86-
{
87-
return response;
88-
}
89-
if !selected_route
90-
.is_some_and(NativeHttp1RouteProxyRoute::https_redirect_exempt_or_redirect)
91-
&& let Some(response) = https_redirect_response(
92-
&request,
93-
&self.https_redirect,
94-
&self.fallback_response_headers,
95-
)
96-
{
97-
return response;
98-
}
9972
let concurrency_route = decoded_policy_route.or(selected_route);
10073
// Delay-mode rate limiting sleeps are still live downstream work.
10174
// Count them against concurrency so an attacker cannot park
@@ -126,6 +99,39 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
12699
.close_connection();
127100
}
128101
}
102+
#[cfg(feature = "wasm")]
103+
let selected_route = {
104+
match self
105+
.wasm_route_decision(&request, &path, selected_route)
106+
.await
107+
{
108+
NativeWasmRouteResolution::Continue => selected_route,
109+
NativeWasmRouteResolution::Select(route) => {
110+
if !route.access.allows(client_ip, tls_identity, geo_context) {
111+
return NativeHttp1Response::new(403, "Forbidden", b"forbidden\n")
112+
.close_connection();
113+
}
114+
Some(route)
115+
}
116+
NativeWasmRouteResolution::Reject(response) => return response,
117+
}
118+
};
119+
#[cfg(feature = "wasm")]
120+
if let Some(response) =
121+
wasm_access_rejection(self, decoded_policy_route.or(selected_route)).await
122+
{
123+
return response;
124+
}
125+
if !selected_route
126+
.is_some_and(NativeHttp1RouteProxyRoute::https_redirect_exempt_or_redirect)
127+
&& let Some(response) = https_redirect_response(
128+
&request,
129+
&self.https_redirect,
130+
&self.fallback_response_headers,
131+
)
132+
{
133+
return response;
134+
}
129135
if let Some(route) = selected_route {
130136
if let Some(response) = native_grpc_rejection_response(&route.grpc, &request) {
131137
return response;

crates/fluxheim-server/src/native_http1_route_proxy_tests/wasm.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,36 @@ async fn native_wasm_route_decision_fails_closed_for_unconfigured_branch() {
344344
assert!(response.ends_with("wasm route decision unavailable\n"));
345345
}
346346

347+
#[tokio::test]
348+
async fn native_wasm_route_decision_does_not_run_before_route_acl() {
349+
let fixture = WasmRouteFixture::new(&[("router", WasmPluginBody::RouteDecision)]);
350+
let upstream = super::upstream_expect_path("/never", "unexpected").await;
351+
let mut config = fixture.config_with_attachments(
352+
upstream,
353+
vec![wasm_vhost_attachment_phase(
354+
"router",
355+
100,
356+
fluxheim_config::WasmPluginPhase::RouteDecision,
357+
)],
358+
);
359+
config.vhosts[0].routes[0].access = fluxheim_config::AccessPolicyConfig {
360+
deny: vec!["127.0.0.1".to_owned()],
361+
..Default::default()
362+
};
363+
let router =
364+
NativeHttp1HostRouter::from_config(&config, DownstreamHttp1Policy::default(), 0).unwrap();
365+
let proxy = router_listener(router).await;
366+
367+
let response = downstream_request(
368+
proxy,
369+
"GET /route HTTP/1.1\r\nHost: route.test\r\nX-Canary: 1\r\nConnection: close\r\n\r\n",
370+
)
371+
.await;
372+
373+
assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n"));
374+
assert!(response.ends_with("forbidden\n"));
375+
}
376+
347377
#[cfg(feature = "load-balancer")]
348378
#[tokio::test]
349379
async fn native_wasm_route_decision_selects_configured_load_balanced_route() {

crates/fluxheim-wasm/src/runtime.rs

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2-
use std::sync::{Arc, OnceLock, mpsc};
2+
use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc};
33
use std::thread;
44
use std::time::{Duration, Instant};
55

@@ -142,22 +142,54 @@ struct RuntimeStoreState {
142142
limits: StoreLimits,
143143
}
144144

145+
#[cfg(test)]
145146
#[derive(Debug)]
146147
struct CounterPermit {
147148
counter: &'static AtomicUsize,
148149
}
149150

151+
struct CompileSlotPool {
152+
active: Mutex<usize>,
153+
available: Condvar,
154+
}
155+
156+
struct CompileSlotPermit {
157+
pool: &'static CompileSlotPool,
158+
}
159+
160+
#[cfg(test)]
150161
impl Drop for CounterPermit {
151162
fn drop(&mut self) {
152163
self.counter.fetch_sub(1, Ordering::AcqRel);
153164
}
154165
}
155166

167+
impl Drop for CompileSlotPermit {
168+
fn drop(&mut self) {
169+
let mut active = self.pool.active.lock().unwrap_or_else(|poisoned| {
170+
let _ = poisoned;
171+
std::process::abort();
172+
});
173+
*active = active.saturating_sub(1);
174+
self.pool.available.notify_one();
175+
}
176+
}
177+
178+
#[cfg(test)]
156179
fn compile_slots() -> &'static AtomicUsize {
157180
static SLOTS: OnceLock<AtomicUsize> = OnceLock::new();
158181
SLOTS.get_or_init(|| AtomicUsize::new(0))
159182
}
160183

184+
fn compile_slot_pool() -> &'static CompileSlotPool {
185+
static POOL: OnceLock<CompileSlotPool> = OnceLock::new();
186+
POOL.get_or_init(|| CompileSlotPool {
187+
active: Mutex::new(0),
188+
available: Condvar::new(),
189+
})
190+
}
191+
192+
#[cfg(test)]
161193
fn acquire_counter_permit(
162194
counter: &'static AtomicUsize,
163195
limit: usize,
@@ -180,18 +212,35 @@ fn acquire_counter_permit(
180212
}
181213

182214
fn acquire_counter_permit_with_timeout(
183-
counter: &'static AtomicUsize,
215+
pool: &'static CompileSlotPool,
184216
limit: usize,
185217
timeout: Duration,
186-
) -> Result<CounterPermit, WasmExecutionError> {
218+
) -> Result<CompileSlotPermit, WasmExecutionError> {
187219
let started = Instant::now();
220+
let mut active = pool.active.lock().unwrap_or_else(|poisoned| {
221+
let _ = poisoned;
222+
std::process::abort();
223+
});
188224
loop {
189-
match acquire_counter_permit(counter, limit) {
190-
Ok(permit) => return Ok(permit),
191-
Err(WasmExecutionError::CompileConcurrencyLimit) if started.elapsed() < timeout => {
192-
thread::sleep(Duration::from_millis(1));
193-
}
194-
Err(error) => return Err(error),
225+
if *active < limit {
226+
*active += 1;
227+
return Ok(CompileSlotPermit { pool });
228+
}
229+
let Some(remaining) = timeout.checked_sub(started.elapsed()) else {
230+
return Err(WasmExecutionError::CompileConcurrencyLimit);
231+
};
232+
if remaining.is_zero() {
233+
return Err(WasmExecutionError::CompileConcurrencyLimit);
234+
}
235+
let (next_active, wait) = pool
236+
.available
237+
.wait_timeout(active, remaining)
238+
.unwrap_or_else(|_| {
239+
std::process::abort();
240+
});
241+
active = next_active;
242+
if wait.timed_out() && *active >= limit {
243+
return Err(WasmExecutionError::CompileConcurrencyLimit);
195244
}
196245
}
197246
}
@@ -331,23 +380,46 @@ impl FluxWasmRuntime {
331380
}
332381

333382
fn compile_module(&self, plugin: &WasmPluginFile) -> Result<Module, WasmExecutionError> {
334-
self.compile_module_with_counter(plugin, compile_slots(), MAX_CONCURRENT_COMPILES)
383+
self.compile_module_with_slot_pool(plugin, compile_slot_pool(), MAX_CONCURRENT_COMPILES)
335384
}
336385

337-
fn compile_module_with_counter(
386+
fn compile_module_with_slot_pool(
338387
&self,
339388
plugin: &WasmPluginFile,
340-
counter: &'static AtomicUsize,
389+
pool: &'static CompileSlotPool,
341390
limit: usize,
342391
) -> Result<Module, WasmExecutionError> {
343392
let started = Instant::now();
344393
let compile_permit =
345-
acquire_counter_permit_with_timeout(counter, limit, self.limits.compile_timeout)?;
394+
acquire_counter_permit_with_timeout(pool, limit, self.limits.compile_timeout)?;
346395
let remaining_timeout = self
347396
.limits
348397
.compile_timeout
349398
.checked_sub(started.elapsed())
350399
.unwrap_or(Duration::ZERO);
400+
self.compile_module_with_permit(plugin, compile_permit, remaining_timeout)
401+
}
402+
403+
#[cfg(test)]
404+
fn compile_module_with_counter(
405+
&self,
406+
plugin: &WasmPluginFile,
407+
counter: &'static AtomicUsize,
408+
limit: usize,
409+
) -> Result<Module, WasmExecutionError> {
410+
let compile_permit = acquire_counter_permit(counter, limit)?;
411+
self.compile_module_with_permit(plugin, compile_permit, self.limits.compile_timeout)
412+
}
413+
414+
fn compile_module_with_permit<P>(
415+
&self,
416+
plugin: &WasmPluginFile,
417+
compile_permit: P,
418+
timeout: Duration,
419+
) -> Result<Module, WasmExecutionError>
420+
where
421+
P: Send + 'static,
422+
{
351423
let engine = self.engine.clone();
352424
let bytes = plugin.bytes().to_vec();
353425
let (result_sender, result_receiver) = mpsc::sync_channel(1);
@@ -358,7 +430,7 @@ impl FluxWasmRuntime {
358430
let _ = result_sender.send(result);
359431
});
360432

361-
match result_receiver.recv_timeout(remaining_timeout) {
433+
match result_receiver.recv_timeout(timeout) {
362434
Ok(result) => result,
363435
Err(mpsc::RecvTimeoutError::Timeout) => Err(WasmExecutionError::CompileTimeout {
364436
timeout_ms: self.limits.compile_timeout.as_millis(),

docs/wasm-extensibility.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ Configured persistence on a selected route also remains Fluxheim-owned. The
142142
backend after a Wasm route decision. Plugins do not provide arbitrary
143143
persistence keys in this preview.
144144

145+
Route decisions run only after the built-in vhost/preselected-route ACL,
146+
rate-limit, and concurrency gates. If the plugin selects a different configured
147+
route, that selected route's ACL is checked before Fluxheim proceeds.
148+
145149
Allowed hooks:
146150

147151
- request headers before upstream selection;

release-notes/RELEASE_NOTES_1.7.3.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,16 @@ route matching, or override built-in Fluxheim access policy.
3636
- `route-decision` hooks cannot create destinations or bypass route matchers.
3737
A selected branch must map to an existing configured route with a matching
3838
method and path.
39-
- Built-in vhost and route ACLs, rate limits, concurrency limits, body limits,
40-
redirect policy, and request/response header policy still apply after the
41-
Wasm decision selects a route.
39+
- Built-in vhost and preselected-route ACLs, rate limits, and concurrency
40+
limits run before `route-decision` execution, so denied or shaped clients
41+
cannot spend the process-wide Wasm admission budget first.
42+
- Selected-route ACLs, body limits, redirect policy, and request/response
43+
header policy still apply after the Wasm decision selects a route.
4244
- If a plugin selects an unavailable branch, Fluxheim returns `503` rather than
4345
falling back silently.
46+
- Wasm module compilation now waits for a bounded compile slot with a condition
47+
variable inside the configured compile timeout instead of polling in 1 ms
48+
sleeps under startup/reload contention.
4449
- The `wasm` feature remains optional and is still rejected with
4550
`privacy-mode`.
4651

0 commit comments

Comments
 (0)