Skip to content

Commit 72ce5ef

Browse files
committed
Enforce selected wasm route limits
1 parent 5317240 commit 72ce5ef

5 files changed

Lines changed: 160 additions & 22 deletions

File tree

crates/fluxheim-server/src/native_http1_route_proxy_handler.rs

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,21 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
6969
return NativeHttp1Response::new(403, "Forbidden", b"forbidden\n")
7070
.close_connection();
7171
}
72-
let concurrency_route = decoded_policy_route.or(selected_route);
7372
// Delay-mode rate limiting sleeps are still live downstream work.
7473
// Count them against concurrency so an attacker cannot park
7574
// unlimited delayed tasks outside the configured vhost/route cap.
76-
let _concurrency_permits =
77-
match self.acquire_concurrency_permits(concurrency_route).await {
78-
Ok(permits) => permits,
79-
Err(status) => {
80-
return NativeHttp1Response::new(
81-
status,
82-
"Too Many Requests",
83-
b"too many requests\n",
84-
)
85-
.close_connection();
86-
}
87-
};
88-
match self.check_rate_limits(concurrency_route, client_ip) {
75+
let _vhost_concurrency_permit = match self.acquire_vhost_concurrency_permit().await {
76+
Ok(permit) => permit,
77+
Err(status) => {
78+
return NativeHttp1Response::new(
79+
status,
80+
"Too Many Requests",
81+
b"too many requests\n",
82+
)
83+
.close_connection();
84+
}
85+
};
86+
match self.check_vhost_rate_limit(client_ip) {
8987
NativeRateLimitDecision::Allow => {}
9088
NativeRateLimitDecision::Delay(delay) => {
9189
tokio::time::sleep(delay).await;
@@ -116,6 +114,34 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
116114
NativeWasmRouteResolution::Reject(response) => return response,
117115
}
118116
};
117+
let _route_concurrency_permits = match self
118+
.acquire_route_concurrency_permits(decoded_policy_route, selected_route)
119+
.await
120+
{
121+
Ok(permits) => permits,
122+
Err(status) => {
123+
return NativeHttp1Response::new(
124+
status,
125+
"Too Many Requests",
126+
b"too many requests\n",
127+
)
128+
.close_connection();
129+
}
130+
};
131+
match self.check_route_rate_limits(decoded_policy_route, selected_route, client_ip) {
132+
NativeRateLimitDecision::Allow => {}
133+
NativeRateLimitDecision::Delay(delay) => {
134+
tokio::time::sleep(delay).await;
135+
}
136+
NativeRateLimitDecision::Reject(status) => {
137+
return NativeHttp1Response::new(
138+
status,
139+
"Too Many Requests",
140+
b"rate limited\n",
141+
)
142+
.close_connection();
143+
}
144+
}
119145
#[cfg(feature = "wasm")]
120146
if let Some(response) =
121147
wasm_access_rejection(self, decoded_policy_route.or(selected_route)).await

crates/fluxheim-server/src/native_http1_route_proxy_policy.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,36 @@ impl NativeHttp1RouteProxy {
178178
.unwrap_or(NativeRateLimitDecision::Allow)
179179
}
180180

181+
pub(crate) fn check_vhost_rate_limit(
182+
&self,
183+
client_ip: Option<IpAddr>,
184+
) -> NativeRateLimitDecision {
185+
self.rate_limit.check(client_ip)
186+
}
187+
188+
pub(crate) fn check_route_rate_limits(
189+
&self,
190+
first: Option<&NativeHttp1RouteProxyRoute>,
191+
second: Option<&NativeHttp1RouteProxyRoute>,
192+
client_ip: Option<IpAddr>,
193+
) -> NativeRateLimitDecision {
194+
let mut delay = None;
195+
for route in unique_route_pair(first, second) {
196+
match route.rate_limit.check(client_ip) {
197+
NativeRateLimitDecision::Allow => {}
198+
NativeRateLimitDecision::Delay(route_delay) => {
199+
delay = Some(
200+
delay.map_or(route_delay, |current: Duration| current.max(route_delay)),
201+
);
202+
}
203+
decision => return decision,
204+
}
205+
}
206+
delay
207+
.map(NativeRateLimitDecision::Delay)
208+
.unwrap_or(NativeRateLimitDecision::Allow)
209+
}
210+
181211
pub(crate) async fn acquire_concurrency_permits(
182212
&self,
183213
route: Option<&NativeHttp1RouteProxyRoute>,
@@ -193,6 +223,37 @@ impl NativeHttp1RouteProxy {
193223
}
194224
Ok(permits)
195225
}
226+
227+
pub(crate) async fn acquire_vhost_concurrency_permit(
228+
&self,
229+
) -> Result<Option<NativeConcurrencyPermit>, u16> {
230+
self.concurrency.acquire().await
231+
}
232+
233+
pub(crate) async fn acquire_route_concurrency_permits(
234+
&self,
235+
first: Option<&NativeHttp1RouteProxyRoute>,
236+
second: Option<&NativeHttp1RouteProxyRoute>,
237+
) -> Result<Vec<NativeConcurrencyPermit>, u16> {
238+
let mut permits = Vec::with_capacity(2);
239+
for route in unique_route_pair(first, second) {
240+
if let Some(permit) = route.concurrency.acquire().await? {
241+
permits.push(permit);
242+
}
243+
}
244+
Ok(permits)
245+
}
246+
}
247+
248+
fn unique_route_pair<'a>(
249+
first: Option<&'a NativeHttp1RouteProxyRoute>,
250+
second: Option<&'a NativeHttp1RouteProxyRoute>,
251+
) -> impl Iterator<Item = &'a NativeHttp1RouteProxyRoute> {
252+
first.into_iter().chain(
253+
second
254+
.into_iter()
255+
.filter(move |route| first.is_none_or(|first| !std::ptr::eq(first, *route))),
256+
)
196257
}
197258

198259
impl NativeHttp1RouteProxyRoute {

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,52 @@ async fn native_wasm_route_decision_does_not_run_before_route_acl() {
374374
assert!(response.ends_with("forbidden\n"));
375375
}
376376

377+
#[tokio::test]
378+
async fn native_wasm_route_decision_enforces_selected_route_rate_limit() {
379+
let fixture = WasmRouteFixture::new(&[("router", WasmPluginBody::RouteDecision)]);
380+
let stable = upstream_expect_body("/limit/item", "stable").await;
381+
let canary = upstream_expect_body("/limit/item", "canary").await;
382+
let mut config = fixture.config_with_attachments(
383+
stable,
384+
vec![wasm_vhost_attachment_phase(
385+
"router",
386+
100,
387+
fluxheim_config::WasmPluginPhase::RouteDecision,
388+
)],
389+
);
390+
let mut canary_route = named_proxy_route("canary", "/limit", canary);
391+
canary_route.rate_limit = fluxheim_config::RateLimitConfig {
392+
enabled: true,
393+
requests_per_second: 1,
394+
burst: 1,
395+
status: 429,
396+
..Default::default()
397+
};
398+
config.vhosts[0].routes = vec![
399+
named_proxy_route("standard", "/limit", stable),
400+
canary_route,
401+
];
402+
let router =
403+
NativeHttp1HostRouter::from_config(&config, DownstreamHttp1Policy::default(), 0).unwrap();
404+
let proxy = router_listener(router).await;
405+
406+
let first = downstream_request(
407+
proxy,
408+
"GET /limit/item HTTP/1.1\r\nHost: route.test\r\nX-Canary: 1\r\nConnection: close\r\n\r\n",
409+
)
410+
.await;
411+
let second = downstream_request(
412+
proxy,
413+
"GET /limit/item HTTP/1.1\r\nHost: route.test\r\nX-Canary: 1\r\nConnection: close\r\n\r\n",
414+
)
415+
.await;
416+
417+
assert!(first.starts_with("HTTP/1.1 200 OK\r\n"));
418+
assert!(first.ends_with("canary"));
419+
assert!(second.starts_with("HTTP/1.1 429 Too Many Requests\r\n"));
420+
assert!(second.ends_with("rate limited\n"));
421+
}
422+
377423
#[cfg(feature = "load-balancer")]
378424
#[tokio::test]
379425
async fn native_wasm_route_decision_selects_configured_load_balanced_route() {

docs/wasm-extensibility.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +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.
145+
Route decisions run only after the built-in vhost ACL, vhost rate-limit, vhost
146+
concurrency, and preselected/decoded-route ACL gates. If the plugin selects a
147+
different configured route, that selected route's ACL and route-specific
148+
rate/concurrency limits are checked before Fluxheim proceeds.
148149

149150
Allowed hooks:
150151

release-notes/RELEASE_NOTES_1.7.3.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,15 @@ 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 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.
39+
- Built-in vhost ACLs, vhost rate limits, and vhost concurrency limits run
40+
before `route-decision` execution, so denied or shaped clients cannot spend
41+
the process-wide Wasm admission budget first.
42+
- Built-in preselected/decoded route ACLs run before `route-decision`
43+
execution, while selected-route ACLs and route-specific rate/concurrency
44+
limits run after the final route decision. A plugin-selected route cannot
45+
bypass its own configured route policy.
46+
- Selected-route body limits, redirect policy, and request/response header
47+
policy still apply after the Wasm decision selects a route.
4448
- If a plugin selects an unavailable branch, Fluxheim returns `503` rather than
4549
falling back silently.
4650
- Wasm module compilation now waits for a bounded compile slot with a condition

0 commit comments

Comments
 (0)