Skip to content

Commit 2b38c9d

Browse files
committed
Add proxy ABI compatibility fixture
1 parent a39ed11 commit 2b38c9d

6 files changed

Lines changed: 120 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ behavior when the change improves security or project direction.
1616
- Add explicit host-call namespace validation so `fluxheim-policy-v1` and
1717
`proxy-wasm-preview` plugins cannot accidentally share host-call surfaces.
1818
- Add deterministic unsupported-call rejection stubs for the proxy-ABI preview
19-
namespace in native HTTP/1 Wasm hook execution.
20-
- Add live native HTTP/1 coverage proving a proxy-ABI preview plugin that calls
21-
an unsupported host function fails closed before reaching the upstream.
19+
namespace and reject unbound host imports before Wasm instantiation.
20+
- Add live native HTTP/1 coverage using the canonical proxy-wasm
21+
`env.proxy_log(i32, i32, i32) -> i32` import, proving that an unsupported
22+
proxy-oriented call fails closed before reaching the upstream.
2223

2324
### Security
2425

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,8 @@ async fn native_wasm_access_decision_fails_closed_on_trap() {
134134

135135
#[cfg(feature = "wasm-proxy-abi")]
136136
#[tokio::test]
137-
async fn native_wasm_proxy_preview_unsupported_call_fails_closed() {
138-
let fixture =
139-
WasmRouteFixture::new(&[("proxy_preview", WasmPluginBody::ProxyPreviewUnsupportedCall)]);
137+
async fn native_wasm_proxy_preview_log_call_fails_closed() {
138+
let fixture = WasmRouteFixture::new(&[("proxy_preview", WasmPluginBody::ProxyPreviewLogCall)]);
140139
let upstream = super::upstream_expect_path("/never", "unexpected").await;
141140
let mut config = fixture.config_with_attachments(
142141
upstream,
@@ -1836,7 +1835,7 @@ enum WasmPluginBody {
18361835
CacheLookupPolicyExample,
18371836
CacheStorePolicyExample,
18381837
Trap,
1839-
ProxyPreviewUnsupportedCall,
1838+
ProxyPreviewLogCall,
18401839
BusyLoop,
18411840
}
18421841

@@ -2125,14 +2124,15 @@ impl WasmPluginBody {
21252124
r#"(module (func (export "fluxheim_access_decision") (result i32) unreachable))"#
21262125
.to_owned()
21272126
}
2128-
Self::ProxyPreviewUnsupportedCall => {
2127+
Self::ProxyPreviewLogCall => {
21292128
r#"
21302129
(module
2131-
(import "proxy_wasm_preview" "unsupported_call" (func $unsupported (param i32 i32) (result i32)))
2130+
(import "env" "proxy_log" (func $proxy_log (param i32 i32 i32) (result i32)))
21322131
(func (export "fluxheim_access_decision") (result i32)
21332132
i32.const 1
21342133
i32.const 0
2135-
call $unsupported
2134+
i32.const 0
2135+
call $proxy_log
21362136
drop
21372137
i32.const 1))
21382138
"#
@@ -2167,7 +2167,7 @@ fn wasm_plugin(root: &Path, name: &str, body: WasmPluginBody) -> fluxheim_config
21672167
..Default::default()
21682168
})
21692169
};
2170-
let proxy_preview = matches!(body, WasmPluginBody::ProxyPreviewUnsupportedCall);
2170+
let proxy_preview = matches!(body, WasmPluginBody::ProxyPreviewLogCall);
21712171
fluxheim_config::WasmPluginConfig {
21722172
name: name.to_owned(),
21732173
path,
@@ -2225,7 +2225,7 @@ fn wasm_plugin_phases(body: WasmPluginBody) -> Vec<fluxheim_config::WasmPluginPh
22252225
}
22262226
WasmPluginBody::Decision(_)
22272227
| WasmPluginBody::Trap
2228-
| WasmPluginBody::ProxyPreviewUnsupportedCall
2228+
| WasmPluginBody::ProxyPreviewLogCall
22292229
| WasmPluginBody::BusyLoop => vec![fluxheim_config::WasmPluginPhase::AccessDecision],
22302230
}
22312231
}

crates/fluxheim-server/src/native_http1_route_wasm.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const CACHE_STORE_PHASE: &str = "cache-store";
3434
const CACHE_STORE_FUNCTION: &str = "fluxheim_cache_store";
3535
const WASM_HOST_MODULE: &str = "fluxheim_policy_v1";
3636
#[cfg(feature = "wasm-proxy-abi")]
37-
const PROXY_WASM_PREVIEW_HOST_MODULE: &str = "proxy_wasm_preview";
37+
const PROXY_WASM_HOST_MODULE: &str = "env";
3838
const MAX_WASM_HEADER_MUTATIONS: usize = 16;
3939
const MAX_WASM_CACHE_KEY_COMPONENTS: usize = 4;
4040
const MAX_WASM_CACHE_TAGS: usize = 4;
@@ -467,10 +467,12 @@ fn wasm_host_call_namespace_functions(
467467

468468
#[cfg(feature = "wasm-proxy-abi")]
469469
fn wasm_proxy_preview_host_functions() -> Vec<WasmI32HostFunction> {
470-
vec![WasmI32HostFunction::new(
471-
PROXY_WASM_PREVIEW_HOST_MODULE,
472-
"unsupported_call",
473-
|_call_id, _arg| Err("unsupported proxy-wasm preview host call".to_owned()),
470+
vec![WasmI32HostFunction::new_i32x3(
471+
PROXY_WASM_HOST_MODULE,
472+
"proxy_log",
473+
|_level, _message_data, _message_size| {
474+
Err("unsupported proxy-wasm preview host call: env.proxy_log".to_owned())
475+
},
474476
)]
475477
}
476478

crates/fluxheim-wasm/src/runtime.rs

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ pub struct FluxWasmCompiledModuleIdentity {
4040
pub struct WasmI32HostFunction {
4141
module: &'static str,
4242
name: &'static str,
43-
callback: Arc<dyn Fn(i32, i32) -> Result<i32, String> + Send + Sync>,
43+
callback: WasmI32HostCallback,
44+
}
45+
46+
type WasmI32HostCallback2 = dyn Fn(i32, i32) -> Result<i32, String> + Send + Sync;
47+
type WasmI32HostCallback3 = dyn Fn(i32, i32, i32) -> Result<i32, String> + Send + Sync;
48+
49+
#[derive(Clone)]
50+
enum WasmI32HostCallback {
51+
Two(Arc<WasmI32HostCallback2>),
52+
Three(Arc<WasmI32HostCallback3>),
4453
}
4554

4655
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -122,7 +131,19 @@ impl WasmI32HostFunction {
122131
Self {
123132
module,
124133
name,
125-
callback: Arc::new(callback),
134+
callback: WasmI32HostCallback::Two(Arc::new(callback)),
135+
}
136+
}
137+
138+
pub fn new_i32x3(
139+
module: &'static str,
140+
name: &'static str,
141+
callback: impl Fn(i32, i32, i32) -> Result<i32, String> + Send + Sync + 'static,
142+
) -> Self {
143+
Self {
144+
module,
145+
name,
146+
callback: WasmI32HostCallback::Three(Arc::new(callback)),
126147
}
127148
}
128149
}
@@ -192,6 +213,8 @@ pub enum WasmExecutionError {
192213
ExecutionTimeout { timeout_ms: u128 },
193214
#[error("wasm module instantiation failed: {0}")]
194215
Instantiate(String),
216+
#[error("wasm host import {module}.{name} is not available in the selected namespace")]
217+
UnsupportedHostImport { module: String, name: String },
195218
#[error("wasm exported function {function:?} is missing or has the wrong type: {message}")]
196219
FunctionType { function: String, message: String },
197220
#[error("wasm execution trapped or exceeded limits: {0}")]
@@ -366,6 +389,16 @@ impl FluxWasmRuntime {
366389
function: &str,
367390
host_functions: Vec<WasmI32HostFunction>,
368391
) -> Result<WasmExecutionOutcome, WasmExecutionError> {
392+
if let Some(import) = module.module.imports().find(|import| {
393+
!host_functions.iter().any(|host_function| {
394+
host_function.module == import.module() && host_function.name == import.name()
395+
})
396+
}) {
397+
return Err(WasmExecutionError::UnsupportedHostImport {
398+
module: import.module().to_owned(),
399+
name: import.name().to_owned(),
400+
});
401+
}
369402
let state = RuntimeStoreState {
370403
limits: StoreLimitsBuilder::new()
371404
.memory_size(self.limits.max_memory_bytes)
@@ -406,16 +439,26 @@ impl FluxWasmRuntime {
406439
let mut linker = Linker::new(&self.engine);
407440
let has_host_functions = !host_functions.is_empty();
408441
for host_function in host_functions {
409-
let callback = host_function.callback.clone();
410-
linker
411-
.func_wrap(
412-
host_function.module,
413-
host_function.name,
414-
move |left: i32, right: i32| -> wasmtime::Result<i32> {
415-
callback(left, right).map_err(wasmtime::Error::msg)
416-
},
417-
)
418-
.map_err(|error| WasmExecutionError::RuntimeSetup(error.to_string()))?;
442+
match host_function.callback {
443+
WasmI32HostCallback::Two(callback) => linker
444+
.func_wrap(
445+
host_function.module,
446+
host_function.name,
447+
move |left: i32, right: i32| -> wasmtime::Result<i32> {
448+
callback(left, right).map_err(wasmtime::Error::msg)
449+
},
450+
)
451+
.map_err(|error| WasmExecutionError::RuntimeSetup(error.to_string()))?,
452+
WasmI32HostCallback::Three(callback) => linker
453+
.func_wrap(
454+
host_function.module,
455+
host_function.name,
456+
move |first: i32, second: i32, third: i32| -> wasmtime::Result<i32> {
457+
callback(first, second, third).map_err(wasmtime::Error::msg)
458+
},
459+
)
460+
.map_err(|error| WasmExecutionError::RuntimeSetup(error.to_string()))?,
461+
};
419462
}
420463
let instance = if has_host_functions {
421464
linker
@@ -554,6 +597,37 @@ mod tests {
554597
assert_eq!(outcome.plugin_sha256.len(), 64);
555598
}
556599

600+
#[test]
601+
fn runtime_rejects_unbound_host_import_before_instantiation() {
602+
let directory = tempfile::tempdir().unwrap();
603+
let plugin_path = write_wat_plugin(
604+
&directory,
605+
r#"
606+
(module
607+
(import "env" "unexpected_host_call" (func $unexpected (param i32 i32) (result i32)))
608+
(func (export "decision") (result i32)
609+
i32.const 0
610+
i32.const 0
611+
call $unexpected))
612+
"#,
613+
);
614+
let limits = WasmSandboxLimits::default();
615+
let plugin =
616+
load_plugin_file(&plugin_path, &[directory.path().to_path_buf()], limits).unwrap();
617+
let runtime = FluxWasmRuntime::new(limits).unwrap();
618+
let module = runtime.compile_plugin_module(&plugin).unwrap();
619+
620+
let error = runtime
621+
.run_compiled_i32_no_args(&module, "decision")
622+
.unwrap_err();
623+
624+
assert!(matches!(
625+
error,
626+
WasmExecutionError::UnsupportedHostImport { module, name }
627+
if module == "env" && name == "unexpected_host_call"
628+
));
629+
}
630+
557631
#[test]
558632
fn compiled_module_execution_does_not_need_compile_slots() {
559633
let counter = Box::leak(Box::new(AtomicUsize::new(0)));

docs/wasm-extensibility.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ unchanged. Unsupported preview host calls are rejected deterministically through
6666
the plugin fail mode, and security-decision plugins still fail closed. Fluxheim
6767
keeps this namespace separate from `fluxheim-policy-v1` so native policy hooks
6868
cannot accidentally bind to a future proxy-oriented compatibility surface.
69+
The `1.7.7` compatibility fixture imports the canonical proxy-wasm
70+
`env.proxy_log(i32, i32, i32) -> i32` function and verifies that invoking it is
71+
rejected before the origin is reached. This deliberately tests a real ABI shape
72+
without claiming logging or guest-memory access semantics that are not yet
73+
implemented.
74+
Imports that are not explicitly bound for the selected namespace are rejected
75+
before module instantiation. A plugin therefore cannot obtain a host capability
76+
by declaring an unexpected module or function name.
6977

7078
## Design Goals
7179

release-notes/RELEASE_NOTES_1.7.7.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ deterministic unsupported-call rejection.
1717
- Add native HTTP/1 proxy-ABI preview host-call stubs that reject unsupported
1818
calls deterministically instead of silently binding to Fluxheim's native
1919
policy namespace.
20-
- Add a live native HTTP/1 test proving a proxy-ABI preview plugin that calls an
21-
unsupported host function fails closed with `503` before the upstream is
22-
reached.
20+
- Reject module imports that are not explicitly bound for the selected
21+
host-call namespace before Wasm instantiation, with a stable import-specific
22+
error.
23+
- Add a live native HTTP/1 compatibility fixture using the canonical
24+
proxy-wasm `env.proxy_log(i32, i32, i32) -> i32` import and prove that the
25+
unsupported call fails closed with `503` before the upstream is reached.
2326

2427
## Security
2528

0 commit comments

Comments
 (0)