Skip to content

Commit fde1c7b

Browse files
committed
Apply wasm header hooks to php fallback
1 parent ac54c3e commit fde1c7b

7 files changed

Lines changed: 237 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ behavior when the change improves security or project direction.
2828
under the existing plugin fail-mode and admission-budget behavior.
2929
- Keep sensitive headers such as `Authorization`, `Cookie`, and `Set-Cookie`
3030
outside the current Wasm host-call ABI.
31+
- Apply vhost-level Wasm header hooks and fallback response header policy to
32+
PHP-FPM fallback responses, closing the only fallback path that skipped the
33+
new hook family.
34+
- Classify Wasm header-hook path context from the matched pre-rewrite request
35+
path so route tiering remains consistent when `strip_prefix` or rewrite
36+
policy changes the upstream target.
3137

3238
## 1.7.1 - 2026-07-04
3339

crates/fluxheim-server/src/native_http1_route_proxy_handler.rs

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,17 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
152152
.request_headers
153153
.apply(&mut request, Some(&header_context));
154154
#[cfg(feature = "wasm")]
155-
if let Some(response) =
156-
wasm_request_header_rejection(&route.wasm_hooks, &mut request).await
155+
let wasm_response_context = NativeWasmHeaderContext::from_path(&path);
156+
#[cfg(feature = "wasm")]
157+
if let Some(response) = wasm_request_header_rejection(
158+
&route.wasm_hooks,
159+
&mut request,
160+
wasm_response_context,
161+
)
162+
.await
157163
{
158164
return response;
159165
}
160-
#[cfg(feature = "wasm")]
161-
let wasm_response_context = NativeWasmHeaderContext::from_request(&request);
162166
let response = route.handle(request).await;
163167
#[cfg(feature = "wasm")]
164168
let mut response = response;
@@ -178,25 +182,77 @@ impl NativeHttp1Handler for NativeHttp1RouteProxy {
178182
if let Some(php) = &self.fallback_php
179183
&& let Some(resolved) = php.resolve_for_fallback(&path)
180184
{
181-
return php.handle_resolved(request, path, resolved).await;
185+
#[cfg(feature = "wasm")]
186+
let wasm_response_context = NativeWasmHeaderContext::from_path(&path);
187+
#[cfg(feature = "wasm")]
188+
if let Some(response) = wasm_request_header_rejection(
189+
&self.wasm_hooks,
190+
&mut request,
191+
wasm_response_context,
192+
)
193+
.await
194+
{
195+
return response;
196+
}
197+
let mut response = php.handle_resolved(request, path, resolved).await;
198+
self.fallback_response_headers.apply(&mut response);
199+
#[cfg(feature = "wasm")]
200+
if let Some(failure) = wasm_response_header_failure(
201+
&self.wasm_hooks,
202+
wasm_response_context,
203+
&mut response,
204+
)
205+
.await
206+
{
207+
return failure;
208+
}
209+
return response;
182210
}
183211
if let Some(response) = self.fallback_web_response(&request, &path) {
184212
return self.apply_wasm_response_headers(request, response).await;
185213
}
186214
#[cfg(feature = "php-fpm")]
187215
if let Some(php) = &self.fallback_php {
188-
return php.handle(request).await;
216+
#[cfg(feature = "wasm")]
217+
let wasm_response_context = NativeWasmHeaderContext::from_path(&path);
218+
#[cfg(feature = "wasm")]
219+
if let Some(response) = wasm_request_header_rejection(
220+
&self.wasm_hooks,
221+
&mut request,
222+
wasm_response_context,
223+
)
224+
.await
225+
{
226+
return response;
227+
}
228+
let mut response = php.handle(request).await;
229+
self.fallback_response_headers.apply(&mut response);
230+
#[cfg(feature = "wasm")]
231+
if let Some(failure) = wasm_response_header_failure(
232+
&self.wasm_hooks,
233+
wasm_response_context,
234+
&mut response,
235+
)
236+
.await
237+
{
238+
return failure;
239+
}
240+
return response;
189241
}
190242
if let Some(proxy) = &self.fallback {
191243
self.apply_traceparent(&mut request);
192244
#[cfg(feature = "wasm")]
193-
if let Some(response) =
194-
wasm_request_header_rejection(&self.wasm_hooks, &mut request).await
245+
let wasm_response_context = NativeWasmHeaderContext::from_path(&path);
246+
#[cfg(feature = "wasm")]
247+
if let Some(response) = wasm_request_header_rejection(
248+
&self.wasm_hooks,
249+
&mut request,
250+
wasm_response_context,
251+
)
252+
.await
195253
{
196254
return response;
197255
}
198-
#[cfg(feature = "wasm")]
199-
let wasm_response_context = NativeWasmHeaderContext::from_request(&request);
200256
let response = proxy.handle(request).await;
201257
#[cfg(feature = "wasm")]
202258
let mut response = response;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,10 @@ async fn native_route_proxy_php_route_executes_fastcgi_responder() {
169169
#[cfg(feature = "php-fpm")]
170170
#[tokio::test]
171171
async fn native_route_proxy_vhost_php_takes_precedence_over_static_web_for_php_paths() {
172-
let fpm = fastcgi_responder(b"Status: 200 OK\r\nContent-Type: text/plain\r\n\r\nphp-ok").await;
172+
let fpm = fastcgi_responder(
173+
b"Status: 200 OK\r\nContent-Type: text/plain\r\nX-Powered-By: php\r\n\r\nphp-ok",
174+
)
175+
.await;
173176
let root = tempfile::TempDir::new().unwrap();
174177
std::fs::write(
175178
root.path().join("wp-login.php"),
@@ -211,6 +214,7 @@ async fn native_route_proxy_vhost_php_takes_precedence_over_static_web_for_php_p
211214
"unexpected php response: {php_response:?}"
212215
);
213216
assert!(php_response.ends_with("php-ok"));
217+
assert_eq!(response_header(&php_response, "x-powered-by"), None);
214218
assert!(!php_response.contains("<?php"));
215219
assert!(
216220
static_response.starts_with("HTTP/1.1 200 OK\r\n"),

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

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,12 @@ async fn native_wasm_access_decision_enforces_global_admission_budget() {
231231
#[tokio::test]
232232
async fn native_wasm_request_and_response_headers_use_bounded_host_calls() {
233233
let fixture = WasmRouteFixture::new(&[("headers", WasmPluginBody::HeaderPolicy)]);
234-
let upstream = upstream_expect_policy_header().await;
234+
let upstream = upstream_expect_policy_header("/item").await;
235235
let mut config = fixture
236236
.config_with_attachments(upstream, vec![wasm_attachment_all("headers", "route", 100)]);
237237
config.vhosts[0].routes[0].path_exact = None;
238238
config.vhosts[0].routes[0].path_prefix = Some("/gold".to_owned());
239+
config.vhosts[0].routes[0].strip_prefix = Some("/gold".to_owned());
239240
config.vhosts[0].routes[0].redirect = None;
240241
config.vhosts[0].routes[0].proxy = Some(fluxheim_config::ProxyConfig {
241242
upstreams: vec![upstream.to_string()],
@@ -284,6 +285,59 @@ async fn native_wasm_forbidden_header_mutation_fails_closed() {
284285
assert!(response.ends_with("wasm request-headers trap\n"));
285286
}
286287

288+
#[cfg(feature = "php-fpm")]
289+
#[tokio::test]
290+
async fn native_wasm_response_headers_apply_to_php_fpm_fallback() {
291+
let fixture = WasmRouteFixture::new(&[("headers", WasmPluginBody::HeaderPolicy)]);
292+
let fpm = fastcgi_responder(
293+
b"Status: 200 OK\r\nContent-Type: text/plain\r\nX-Powered-By: php\r\n\r\nphp-policy",
294+
)
295+
.await;
296+
let root = tempfile::TempDir::new().unwrap();
297+
std::fs::create_dir(root.path().join("gold")).unwrap();
298+
std::fs::write(
299+
root.path().join("gold").join("index.php"),
300+
b"<?php echo 'ok';",
301+
)
302+
.unwrap();
303+
let mut vhost = native_route_proxy_test_vhost();
304+
vhost.php = fluxheim_config::PhpConfig {
305+
enabled: true,
306+
root: Some(root.path().to_path_buf()),
307+
fpm: fluxheim_config::PhpFpmConfig {
308+
tcp: Some(fpm.to_string()),
309+
allow_private_tcp_upstreams: true,
310+
..Default::default()
311+
},
312+
..Default::default()
313+
};
314+
let mut config = fluxheim_config::Config {
315+
vhosts: vec![vhost],
316+
..Default::default()
317+
};
318+
config.server.default_vhost = Some("route.test".to_owned());
319+
config.wasm = fluxheim_config::WasmConfig {
320+
enabled: true,
321+
plugin_roots: vec![fixture.root.clone()],
322+
plugins: fixture.plugins.clone(),
323+
attachments: vec![wasm_vhost_attachment_all("headers", 100)],
324+
..Default::default()
325+
};
326+
let router =
327+
NativeHttp1HostRouter::from_config(&config, DownstreamHttp1Policy::default(), 0).unwrap();
328+
let proxy = router_listener(router).await;
329+
330+
let response = downstream_get(proxy, "/gold/index.php").await;
331+
332+
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
333+
assert_eq!(
334+
response_header(&response, "x-fluxheim-policy-branch").as_deref(),
335+
Some("gold")
336+
);
337+
assert_eq!(response_header(&response, "x-powered-by"), None);
338+
assert!(response.ends_with("php-policy"));
339+
}
340+
287341
struct WasmRouteFixture {
288342
_directory: TempDir,
289343
root: PathBuf,
@@ -477,6 +531,20 @@ fn wasm_attachment_all(
477531
attachment
478532
}
479533

534+
fn wasm_vhost_attachment_all(plugin: &str, priority: u32) -> fluxheim_config::WasmAttachmentConfig {
535+
fluxheim_config::WasmAttachmentConfig {
536+
plugin: plugin.to_owned(),
537+
vhost: "route.test".to_owned(),
538+
route: None,
539+
phases: vec![
540+
fluxheim_config::WasmPluginPhase::RequestHeaders,
541+
fluxheim_config::WasmPluginPhase::ResponseHeaders,
542+
],
543+
priority,
544+
admission: None,
545+
}
546+
}
547+
480548
fn wasm_attachment_phase(
481549
plugin: &str,
482550
route: &str,
@@ -540,7 +608,7 @@ async fn router_listener(router: NativeHttp1HostRouter) -> std::net::SocketAddr
540608
addr
541609
}
542610

543-
async fn upstream_expect_policy_header() -> std::net::SocketAddr {
611+
async fn upstream_expect_policy_header(expected_path: &'static str) -> std::net::SocketAddr {
544612
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
545613
let addr = listener.local_addr().unwrap();
546614
tokio::spawn(async move {
@@ -559,7 +627,7 @@ async fn upstream_expect_policy_header() -> std::net::SocketAddr {
559627
}
560628
let request = String::from_utf8(request).unwrap();
561629
assert!(
562-
request.starts_with("GET /gold/item HTTP/1.1\r\n"),
630+
request.starts_with(&format!("GET {expected_path} HTTP/1.1\r\n")),
563631
"unexpected upstream request: {request:?}"
564632
);
565633
assert!(
@@ -584,3 +652,71 @@ async fn upstream_expect_policy_header() -> std::net::SocketAddr {
584652
});
585653
addr
586654
}
655+
656+
#[cfg(feature = "php-fpm")]
657+
async fn fastcgi_responder(stdout: &'static [u8]) -> std::net::SocketAddr {
658+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
659+
let addr = listener.local_addr().unwrap();
660+
tokio::spawn(async move {
661+
let (mut stream, _) = listener.accept().await.unwrap();
662+
let mut request_id = 1_u16;
663+
let mut params_done = false;
664+
let mut stdin_done = false;
665+
while !(params_done && stdin_done) {
666+
let (record_type, id, content) = read_fastcgi_record(&mut stream).await;
667+
request_id = id;
668+
match record_type {
669+
4 if content.is_empty() => params_done = true,
670+
5 if content.is_empty() => stdin_done = true,
671+
_ => {}
672+
}
673+
}
674+
write_fastcgi_record(&mut stream, 6, request_id, stdout)
675+
.await
676+
.unwrap();
677+
write_fastcgi_record(&mut stream, 6, request_id, b"")
678+
.await
679+
.unwrap();
680+
write_fastcgi_record(&mut stream, 3, request_id, &[0, 0, 0, 0, 0, 0, 0, 0])
681+
.await
682+
.unwrap();
683+
stream.shutdown().await.unwrap();
684+
});
685+
addr
686+
}
687+
688+
#[cfg(feature = "php-fpm")]
689+
async fn read_fastcgi_record(stream: &mut tokio::net::TcpStream) -> (u8, u16, Vec<u8>) {
690+
let mut header = [0_u8; 8];
691+
stream.read_exact(&mut header).await.unwrap();
692+
let record_type = header[1];
693+
let request_id = u16::from_be_bytes([header[2], header[3]]);
694+
let content_len = u16::from_be_bytes([header[4], header[5]]) as usize;
695+
let padding_len = header[6] as usize;
696+
let mut content = vec![0_u8; content_len];
697+
if content_len > 0 {
698+
stream.read_exact(&mut content).await.unwrap();
699+
}
700+
if padding_len > 0 {
701+
let mut padding = vec![0_u8; padding_len];
702+
stream.read_exact(&mut padding).await.unwrap();
703+
}
704+
(record_type, request_id, content)
705+
}
706+
707+
#[cfg(feature = "php-fpm")]
708+
async fn write_fastcgi_record(
709+
stream: &mut tokio::net::TcpStream,
710+
record_type: u8,
711+
request_id: u16,
712+
content: &[u8],
713+
) -> std::io::Result<()> {
714+
let len = u16::try_from(content.len()).unwrap();
715+
let mut header = [0_u8; 8];
716+
header[0] = 1;
717+
header[1] = record_type;
718+
header[2..4].copy_from_slice(&request_id.to_be_bytes());
719+
header[4..6].copy_from_slice(&len.to_be_bytes());
720+
stream.write_all(&header).await?;
721+
stream.write_all(content).await
722+
}

crates/fluxheim-server/src/native_http1_route_wasm.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,11 @@ impl NativeWasmHooks {
273273
pub(crate) async fn apply_request_headers(
274274
&self,
275275
request: &mut NativeHttp1Request,
276+
context: NativeWasmHeaderContext,
276277
) -> Result<(), NativeWasmHeaderError> {
277278
if self.request_headers.is_empty() {
278279
return Ok(());
279280
}
280-
let context = NativeWasmHeaderContext::from_request(request);
281281
for hook in &self.request_headers {
282282
let mutations = hook
283283
.run_header_mutations(
@@ -344,8 +344,9 @@ pub(crate) async fn wasm_access_rejection_status(
344344
pub(crate) async fn wasm_request_header_rejection(
345345
hooks: &NativeWasmHooks,
346346
request: &mut NativeHttp1Request,
347+
context: NativeWasmHeaderContext,
347348
) -> Option<NativeHttp1Response> {
348-
match hooks.apply_request_headers(request).await {
349+
match hooks.apply_request_headers(request, context).await {
349350
Ok(()) => None,
350351
Err(NativeWasmHeaderError::Failed(phase, outcome)) => Some(
351352
NativeHttp1Response::new(
@@ -586,6 +587,12 @@ struct NativeWasmHeaderMutations {
586587
}
587588

588589
impl NativeWasmHeaderContext {
590+
pub(crate) fn from_path(path: &str) -> Self {
591+
Self {
592+
path_class: wasm_path_class(path),
593+
}
594+
}
595+
589596
pub(crate) fn from_request(request: &NativeHttp1Request) -> Self {
590597
let path_class = request_path_and_query(request)
591598
.map(|(path, _)| wasm_path_class(&path))

docs/modularity-exceptions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ line adds broader Wasm phases.
2525
| `crates/fluxheim-config/src/config_tests_wasm.rs` | 531 | Wasm validation coverage is intentionally broad for the first live-hook release. | Split attachment-order, admission-budget, and reload-classification cases into focused Wasm test modules. |
2626
| `crates/fluxheim-config/src/config_wasm.rs` | 840 | The first live Wasm schema keeps validation, defaults, attachment planning, and admission limits together while the API settles. | Split into `config_wasm_limits`, `config_wasm_attachments`, and `config_wasm_validate` modules after 1.7.1 behavior is locked. |
2727
| `crates/fluxheim-wasm/src/manifest.rs` | 502 | Manifest parsing and validation grew with ABI/runtime compatibility checks. | Move manifest validation helpers and tests into focused modules. |
28-
| `crates/fluxheim-server/src/native_http1_route_proxy_tests/wasm.rs` | 586 | Live Wasm hook coverage now includes access decisions plus request/response header mutation in one fixture file. | Split access-decision and header-hook live tests into separate modules before adding routing/cache Wasm phases. |
29-
| `crates/fluxheim-server/src/native_http1_route_wasm.rs` | 805 | The native hook bridge now owns registry lookup, admission, access decisions, and the first bounded header host-call ABI. | Split header host-call state/mutation helpers into a focused `native_http1_route_wasm_headers` module. |
28+
| `crates/fluxheim-server/src/native_http1_route_proxy_tests/wasm.rs` | 722 | Live Wasm hook coverage now includes access decisions, request/response header mutation, rewrite-context behavior, and PHP-FPM fallback coverage in one fixture file. | Split access-decision, header-hook, and PHP-FPM fallback live tests into separate modules before adding routing/cache Wasm phases. |
29+
| `crates/fluxheim-server/src/native_http1_route_wasm.rs` | 812 | The native hook bridge now owns registry lookup, admission, access decisions, and the first bounded header host-call ABI. | Split header host-call state/mutation helpers into a focused `native_http1_route_wasm_headers` module. |
3030
| `crates/fluxheim-wasm/src/runtime.rs` | 634 | Runtime construction now includes compile caching, host bindings, and execution policy checks. | Split compile-cache, execution, and host-call helpers into separate runtime submodules. |
3131
| `src/metrics.rs` | 505 | Root metrics compatibility wrappers remain just over the target after native/Wasm additions. | Move remaining Wasm metric wiring into the observability crate or a focused root adapter. |

release-notes/RELEASE_NOTES_1.7.2.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ bodies, filesystem access, network access, or admin APIs.
2525
- Add fail-closed coverage for forbidden header mutations. Invalid host-call
2626
IDs trap the plugin invocation and return `503` unless the plugin is
2727
explicitly configured for fail-open behavior on a non-security phase.
28+
- Apply vhost-level Wasm header hooks to PHP-FPM fallback responses as well as
29+
route, static, and generic proxy paths.
30+
- Apply the shared fallback response header policy to PHP-FPM fallback
31+
responses, including the default `x-powered-by` removal.
32+
- Compute Wasm header-hook path context from the matched pre-rewrite request
33+
path so path-class policy stays stable when a route strips or rewrites the
34+
upstream target.
2835

2936
## Security Notes
3037

@@ -36,6 +43,9 @@ bodies, filesystem access, network access, or admin APIs.
3643
the current Wasm host-call surface.
3744
- Built-in Fluxheim ACLs and the `access-decision` chain still run before
3845
header hooks. Wasm header hooks cannot override built-in access policy.
46+
- PHP-FPM fallback traffic now goes through the same vhost-level header-hook
47+
and fallback response-header post-processing as other fallback response
48+
paths.
3949
- The `wasm` feature remains optional and is still rejected with
4050
`privacy-mode`.
4151

0 commit comments

Comments
 (0)