From d6ce3db7a59438a2e4a00315a62df18b6ebef11e Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 2 Jul 2026 17:35:41 +0200 Subject: [PATCH 1/8] ci: run full barbacane-test integration suite The Integration Tests job only ran the crate's lib tests (cargo test -p barbacane-test --lib), leaving every tests/*.rs integration binary (proxy, plugins, streaming, validation, workload, routing, auth, cache, etc.) out of CI. Those suites drifted before (stale assertions surfaced in #95/#97 once they finally ran). Broaden the job to compile the crate once and run the lib tests plus all integration binaries. The security target is excluded here (it needs PostgreSQL + the control-plane binary and has its own security-suite job). Targets are discovered from tests/*.rs rather than hard-coded, so new suites are picked up automatically. No new services required: the kafka/nats tests are broker-unavailable negative tests, and the only Postgres-dependent suite is security. --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f010de9..cdd91f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,8 +288,18 @@ jobs: run: chmod +x target/debug/barbacane - name: Run integration tests - # Run gateway integration tests from barbacane-test crate - run: cargo test -p barbacane-test --lib -- --test-threads=2 + # Run the full barbacane-test suite: the lib tests plus every + # integration binary in tests/*.rs. The `security` target is excluded + # here because it needs PostgreSQL and the control-plane binary — it has + # its own `security-suite` job below. Targets are discovered rather than + # hard-coded so new suites are picked up automatically. + run: | + targets=$(ls crates/barbacane-test/tests/*.rs \ + | xargs -n1 basename | sed 's/\.rs$//' \ + | grep -vx security \ + | sed 's/^/--test /' | tr '\n' ' ') + echo "Running integration targets: $targets" + cargo test -p barbacane-test --lib $targets -- --test-threads=2 # Adversarial security suite (crates/barbacane-test/tests/security/). Runs the # gateway (and, for authz, the control plane) end-to-end, so it needs the From 485a9533e87d458583b3ea1f12b2bcb548f14a32 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 2 Jul 2026 18:12:05 +0200 Subject: [PATCH 2/8] ci: install wasm32 target for integration job; widen test gateway boot window Two issues surfaced once the full barbacane-test suite ran in CI: 1. The Integration Tests job installed the toolchain without the wasm32-unknown-unknown target, so barbacane-test's build.rs could not compile the streaming-echo/body-echo fixture WASM plugins (exit 101). The streaming/body suites then had no real coverage (they panic on the missing .wasm, or lean on a stale cached artifact). Add the target so the fixtures build, matching the build and security-suite jobs. 2. ai_gateway::cel_selected_strict_profile_blocks_prompt failed twice with StartupFailed("gateway did not become ready in time"). It boots the same spec as a sibling test that passes, so it is not a spec/code bug: with --test-threads=2 two CEL-heavy gateways cold-boot at once on a shared CI runner and the loser exceeds the 60s health window. Widen the TestGateway readiness timeout to 120s; a real boot hang still fails. --- .github/workflows/ci.yml | 6 ++++++ crates/barbacane-test/src/gateway.rs | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdd91f4..3acc651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + # Needed so barbacane-test's build.rs can compile the fixture WASM + # plugins (streaming-echo, body-echo). Without the target it fails with + # exit 101 and the streaming/body integration tests panic on the + # missing .wasm. + targets: wasm32-unknown-unknown - name: Cache cargo registry uses: actions/cache@v5 diff --git a/crates/barbacane-test/src/gateway.rs b/crates/barbacane-test/src/gateway.rs index 9081631..f82b29d 100644 --- a/crates/barbacane-test/src/gateway.rs +++ b/crates/barbacane-test/src/gateway.rs @@ -261,9 +261,12 @@ impl TestGateway { /// Wait for the gateway to be ready by polling the health endpoint. async fn wait_for_ready(&mut self) -> Result<(), TestError> { let health_url = format!("{}/__barbacane/health", self.base_url()); - // 60-second timeout — larger WASM plugins (e.g. CEL ~1.3 MB) need - // more JIT compile time, especially under heavy parallel test load. - let max_attempts = 600; + // 120-second timeout — larger WASM plugins (e.g. CEL ~1.3 MB) need more + // JIT compile time, and when the full integration suite runs in CI two + // CEL-heavy gateways can cold-boot simultaneously (--test-threads=2) on a + // shared runner, so the loser of that CPU race needs a wider window. A + // genuine boot hang still fails here rather than being masked. + let max_attempts = 1200; let delay = Duration::from_millis(100); for _ in 0..max_attempts { From b736ce5260b4769716e49655fd331498f2675708 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 2 Jul 2026 18:44:08 +0200 Subject: [PATCH 3/8] test(ai_proxy): make shipped-fragment /v1/models test hermetic The shipped-fragment test's /v1/models step exercised the real api.openai.com / api.anthropic.com hosts (the fragment omits base_url for those providers). Locally they fast-fail (401) so the aggregator returns the expected partial 200; in CI they hang, and the accumulated wall-clock trips the ai-proxy plugin's epoch deadline (added in the WASM sandbox hardening), so the dispatcher traps and the gateway returns 502. Redirect all three providers at the wiremock by injecting base_url: env://_BASE_URL into the copied fragment's OpenAI/Anthropic routes, and mock GET /v1/models -> 500 so the partial path fires deterministically and fast. No outbound network, no epoch pressure. The partial-aggregation contract is also covered by the dedicated hermetic models tests. --- crates/barbacane-test/tests/ai_proxy.rs | 63 +++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/barbacane-test/tests/ai_proxy.rs b/crates/barbacane-test/tests/ai_proxy.rs index da293b0..40e503f 100644 --- a/crates/barbacane-test/tests/ai_proxy.rs +++ b/crates/barbacane-test/tests/ai_proxy.rs @@ -1203,23 +1203,24 @@ async fn test_ai_proxy_models_handles_empty_body_from_upstream() { // ========================================================================= // schemas/ai-gateway.yaml — end-to-end test of the shipped fragment. // -// The fragment ships with `env://`-resolved provider keys + OLLAMA_BASE_URL. -// OpenAI and Anthropic use the provider defaults (api.openai.com, etc.) so -// we can't redirect them at wiremock without modifying the fragment. This -// test exercises the only path we can fully isolate end-to-end: +// The fragment ships with `env://`-resolved provider keys + OLLAMA_BASE_URL, +// and OpenAI/Anthropic use the provider defaults (api.openai.com, etc.). To +// keep this test hermetic we inject `base_url: "env://_BASE_URL"` +// into the copied fragment's OpenAI/Anthropic routes and point all three +// providers at the same wiremock. This isolates two paths end-to-end: // -// - OLLAMA_BASE_URL → wiremock (the catch-all route in the fragment) -// - send a request with `model: mistral` → matches catch-all → reaches -// wiremock at /v1/chat/completions → returns the canned completion. -// -// The /v1/models partial-response path is also verified end-to-end: with -// OLLAMA up but OpenAI/Anthropic unreachable, the aggregator returns 200 -// with `partial: true` + warnings for the two failing providers. This is -// the operator's most-likely real-world experience the first time they -// drop the fragment in (real OpenAI/Anthropic keys not yet wired). +// - Chat completions: `model: mistral` → catch-all `*` → Ollama route → +// wiremock `/v1/chat/completions` → canned completion. +// - `/v1/models` partial-response: Ollama `/api/tags` succeeds while the +// OpenAI/Anthropic `/v1/models` calls return 500, so the aggregator +// returns 200 with `partial: true` + a warning per failing provider — +// the operator's most-likely first-run experience (real provider keys +// not yet wired), reproduced deterministically instead of relying on +// real outbound calls (which are non-hermetic and, when slow in CI, trip +// the plugin's wall-clock/epoch deadline and surface as a 502). // ========================================================================= -fn copy_shipped_fragment_to_temp(ollama_url: &str) -> (tempfile::TempDir, std::path::PathBuf) { +fn copy_shipped_fragment_to_temp(mock_url: &str) -> (tempfile::TempDir, std::path::PathBuf) { let temp = tempfile::TempDir::new().expect("temp dir"); let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); @@ -1227,7 +1228,19 @@ fn copy_shipped_fragment_to_temp(ollama_url: &str) -> (tempfile::TempDir, std::p let fragment = repo_root.join("schemas/ai-gateway.yaml"); let plugins = repo_root.join("plugins"); - std::fs::copy(&fragment, temp.path().join("ai-gateway.yaml")).expect("copy fragment"); + // Redirect the OpenAI/Anthropic routes at the wiremock by adding a + // `base_url` sibling to each `api_key` line (the shipped fragment omits + // base_url for these providers, defaulting to the real hosts). + let fragment_src = std::fs::read_to_string(&fragment).expect("read fragment"); + let fragment_src = fragment_src.replace( + " api_key: \"env://ANTHROPIC_API_KEY\"", + " api_key: \"env://ANTHROPIC_API_KEY\"\n base_url: \"env://ANTHROPIC_BASE_URL\"", + ); + let fragment_src = fragment_src.replace( + " api_key: \"env://OPENAI_API_KEY\"", + " api_key: \"env://OPENAI_API_KEY\"\n base_url: \"env://OPENAI_BASE_URL\"", + ); + std::fs::write(temp.path().join("ai-gateway.yaml"), fragment_src).expect("write fragment"); std::fs::write( temp.path().join("barbacane.yaml"), format!( @@ -1237,13 +1250,14 @@ fn copy_shipped_fragment_to_temp(ollama_url: &str) -> (tempfile::TempDir, std::p ) .expect("manifest"); - // Set the env vars the fragment reads via env://. Placeholder strings - // for the API keys (we never actually call those upstreams in this - // test); OLLAMA_BASE_URL points at the wiremock so the catch-all - // route can be exercised. + // Set the env vars the fragment reads via env://. Placeholder API keys + // (the upstreams are wiremock, not real providers); every base_url points + // at the same wiremock so all provider traffic stays local. std::env::set_var("OPENAI_API_KEY", "sk-test-openai"); std::env::set_var("ANTHROPIC_API_KEY", "sk-test-anthropic"); - std::env::set_var("OLLAMA_BASE_URL", ollama_url); + std::env::set_var("OLLAMA_BASE_URL", mock_url); + std::env::set_var("OPENAI_BASE_URL", mock_url); + std::env::set_var("ANTHROPIC_BASE_URL", mock_url); let path = temp.path().join("ai-gateway.yaml"); (temp, path) @@ -1282,6 +1296,15 @@ async fn test_shipped_fragment_chat_completions_and_models_via_ollama() { .mount(&mock_server) .await; + // OpenAI/Anthropic /v1/models fail fast (500) so the aggregator degrades to + // a partial response with a warning per provider — deterministic, and quick + // enough not to trip the plugin's wall-clock deadline. + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(500).set_body_string("upstream down")) + .mount(&mock_server) + .await; + let (_tmp, spec_path) = copy_shipped_fragment_to_temp(&mock_server.uri()); let gateway = TestGateway::from_spec(spec_path.to_str().unwrap()) .await From 47ac1eea03d3b52ac41e65b3bcc268962ad6cdf7 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 2 Jul 2026 19:10:55 +0200 Subject: [PATCH 4/8] ci: run integration binaries with --no-fail-fast cargo test stops at the first test binary that fails, which hides failures in every binary scheduled after it. Broadening CI surfaced one new failing binary per run (ai_gateway -> ai_proxy -> auth) instead of all at once. Run with --no-fail-fast so a single CI run reports the full set of failures across all integration binaries. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3acc651..94b9424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,7 +305,9 @@ jobs: | grep -vx security \ | sed 's/^/--test /' | tr '\n' ' ') echo "Running integration targets: $targets" - cargo test -p barbacane-test --lib $targets -- --test-threads=2 + # --no-fail-fast so a failure in one binary doesn't hide failures in + # the binaries that would run after it; we want the full picture. + cargo test -p barbacane-test --lib $targets --no-fail-fast -- --test-threads=2 # Adversarial security suite (crates/barbacane-test/tests/security/). Runs the # gateway (and, for authz, the control plane) end-to-end, so it needs the From b0ed6f35fe403f0a4e3658ad54901590a93264d8 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 3 Jul 2026 10:31:53 +0200 Subject: [PATCH 5/8] fix(wasm): refresh execution deadline after blocking host I/O The WASM epoch deadline (max_execution_ms, default 100ms) bounds plugin CPU time, but the epoch clock keeps advancing while a plugin is blocked in a host function doing network I/O (broker publish, HTTP call/stream). A slow or unavailable upstream therefore made the plugin resume past its deadline and trap, surfacing as a 500 instead of a clean gateway error. This was observable as an inconsistency: an unavailable NATS broker returned 502 (it fails fast), while an unavailable Kafka broker returned 500 (rskafka retries the connect for ~5s, blowing the 100ms budget, so the dispatch trapped). Store max_execution_ms in PluginState and, in host_kafka_publish, host_nats_publish, host_http_call and host_http_stream, refresh the store's epoch deadline once the blocking call returns. Time spent waiting on native I/O no longer counts against the plugin's CPU-execution budget, so a slow/unreachable upstream yields a clean error response rather than a trap. The CPU guard on actual WASM execution is unchanged. --- crates/barbacane-wasm/src/instance.rs | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/barbacane-wasm/src/instance.rs b/crates/barbacane-wasm/src/instance.rs index 97ce6d1..d63096e 100644 --- a/crates/barbacane-wasm/src/instance.rs +++ b/crates/barbacane-wasm/src/instance.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; -use wasmtime::{Caller, Engine, Instance, Linker, Memory, Store, TypedFunc}; +use wasmtime::{AsContextMut, Caller, Engine, Instance, Linker, Memory, Store, TypedFunc}; use barbacane_plugin_sdk::types::base64_body; use serde::Deserialize; @@ -95,6 +95,12 @@ pub struct PluginState { /// Maximum memory in bytes. pub max_memory: usize, + /// WASM execution budget in milliseconds (the epoch deadline). Blocking host + /// functions refresh the store's epoch deadline to this after network I/O so + /// that time spent waiting on an upstream/broker doesn't count against the + /// plugin's CPU-execution budget and spuriously trap the dispatch. + pub max_execution_ms: u64, + /// HTTP client for outbound requests (shared). pub http_client: Option>, @@ -180,6 +186,7 @@ impl PluginState { output_buffer: Vec::new(), context: RequestContext::default(), max_memory: limits.max_memory_bytes, + max_execution_ms: limits.max_execution_ms, http_client: None, last_http_result: None, secrets: crate::secrets::SecretsStore::new(), @@ -213,6 +220,7 @@ impl PluginState { output_buffer: Vec::new(), context: RequestContext::default(), max_memory: limits.max_memory_bytes, + max_execution_ms: limits.max_execution_ms, http_client: Some(http_client), last_http_result: None, secrets: crate::secrets::SecretsStore::new(), @@ -247,6 +255,7 @@ impl PluginState { output_buffer: Vec::new(), context: RequestContext::default(), max_memory: limits.max_memory_bytes, + max_execution_ms: limits.max_execution_ms, http_client: Some(http_client), last_http_result: None, secrets, @@ -286,6 +295,7 @@ impl PluginState { output_buffer: Vec::new(), context: RequestContext::default(), max_memory: limits.max_memory_bytes, + max_execution_ms: limits.max_execution_ms, http_client, last_http_result: None, secrets, @@ -326,6 +336,7 @@ impl PluginState { output_buffer: Vec::new(), context: RequestContext::default(), max_memory: limits.max_memory_bytes, + max_execution_ms: limits.max_execution_ms, http_client, last_http_result: None, secrets, @@ -1231,6 +1242,11 @@ fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> } }); + // Refresh the execution deadline after blocking HTTP I/O — see + // the host_kafka_publish handler for the rationale. + let deadline = caller.data().max_execution_ms.max(1); + caller.as_context_mut().set_epoch_deadline(deadline); + match response_result { Some((Some(json), body)) => { let len = json.len() as i32; @@ -1401,6 +1417,11 @@ fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> } }); + // Refresh the execution deadline after blocking HTTP I/O — see + // the host_kafka_publish handler for the rationale. + let deadline = caller.data().max_execution_ms.max(1); + caller.as_context_mut().set_epoch_deadline(deadline); + match response_result { Some((Some(json), body)) => { let len = json.len() as i32; @@ -2005,6 +2026,14 @@ fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> } }); + // The publish above blocked on native broker I/O (connect, + // metadata, produce) while the epoch clock advanced. That wait is + // not WASM CPU work, so refresh the execution deadline; otherwise a + // slow or unavailable broker trips the epoch guard and the dispatch + // traps (500) instead of returning a clean broker error (502). + let deadline = caller.data().max_execution_ms.max(1); + caller.as_context_mut().set_epoch_deadline(deadline); + // Serialize the result let result_json = match result { Some(Ok(r)) => serde_json::to_vec(&r), @@ -2104,6 +2133,11 @@ fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> } }); + // Refresh the execution deadline after blocking broker I/O — see + // the host_kafka_publish handler for the rationale. + let deadline = caller.data().max_execution_ms.max(1); + caller.as_context_mut().set_epoch_deadline(deadline); + // Serialize the result let result_json = match result { Some(Ok(r)) => serde_json::to_vec(&r), From 1c9ae2a7f0b97542614737492e3a171070949bae Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 3 Jul 2026 10:32:09 +0200 Subject: [PATCH 6/8] test: repair integration suites exposed by CI broadening Broadening CI to run every barbacane-test integration binary surfaced 21 tests that had rotted while excluded. All are test-side issues (the production behavior they now exercise is correct, and several confirm the security hardening works): - auth JWT (5): tests used a fake signature relying on skip_signature_validation, which the production WASM correctly ignores (it is honored only under the plugin's own cfg(test)). Sign tokens with a real ES256 key (new p256 dev-dep) and embed the matching public JWK in a generated spec. - auth secrets (1): file:// secrets now require BARBACANE_SECRETS_DIR (fail-closed). Inject it via the new TestGateway::from_spec_with_env, which sets per-instance child env vars instead of racing on process globals. - mcp (1): a non-initialize method now needs a session; initialize first. - proxy (3): tests hit the real httpbin.org; point them at a local wiremock so they are hermetic and fast. - plugins/redirect (5): reqwest follows redirects by default, turning the 3xx into a followed 404/200; use a non-redirect client. - plugins/ip-restriction (2): X-Forwarded-For is only trusted from declared trusted_proxies (anti-spoofing); declare loopback so the XFF-based cases are exercised. - plugins/kafka (1): now passes via the host-I/O epoch fix. - streaming (3): the streaming-echo fixture declared capabilities with the wrong key syntax (http_stream = true) instead of host_functions = ["http_call"], and its plugin.toml was not beside the wasm where the compiler reads it. Fix the manifest and have build.rs copy plugin.toml next to the built fixture wasm. --- Cargo.lock | 140 ++++++++++++++++-- crates/barbacane-test/Cargo.toml | 4 + crates/barbacane-test/build.rs | 28 +++- crates/barbacane-test/src/gateway.rs | 25 +++- crates/barbacane-test/tests/auth.rs | 133 +++++++++++++++-- crates/barbacane-test/tests/mcp.rs | 14 +- crates/barbacane-test/tests/plugins.rs | 44 +++--- crates/barbacane-test/tests/proxy.rs | 132 +++++++++++++++-- .../streaming-echo/plugin.toml | 5 +- tests/fixtures/ip-restriction.yaml | 9 ++ 10 files changed, 453 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb0b6ec..b08b481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -108,7 +108,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -539,6 +539,7 @@ dependencies = [ "base64", "flate2", "futures-util", + "p256", "predicates", "rcgen", "reqwest", @@ -589,6 +590,12 @@ dependencies = [ "wat", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1198,6 +1205,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1370,6 +1389,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1401,6 +1434,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1444,7 +1497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1486,6 +1539,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1729,6 +1792,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1789,6 +1853,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -2260,7 +2335,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2662,7 +2737,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2907,6 +2982,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3149,6 +3236,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3198,7 +3294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -3557,6 +3653,16 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3697,7 +3803,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3806,6 +3912,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4079,7 +4199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4384,7 +4504,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5627,7 +5747,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/barbacane-test/Cargo.toml b/crates/barbacane-test/Cargo.toml index 6febd3d..08f0d9c 100644 --- a/crates/barbacane-test/Cargo.toml +++ b/crates/barbacane-test/Cargo.toml @@ -17,6 +17,10 @@ thiserror = { workspace = true } rcgen = { workspace = true } rustls = { workspace = true } base64 = { workspace = true } +# ES256 signing of test JWTs. The jwt-auth plugin enforces real signatures in +# the production WASM (skip_signature_validation is honored only under cfg(test) +# of the plugin's own unit tests), so integration tests must sign properly. +p256 = { version = "0.13", features = ["ecdsa"] } wiremock = "0.6" tokio-tungstenite = { workspace = true } futures-util = { workspace = true } diff --git a/crates/barbacane-test/build.rs b/crates/barbacane-test/build.rs index 2ed4167..0721a5e 100644 --- a/crates/barbacane-test/build.rs +++ b/crates/barbacane-test/build.rs @@ -48,20 +48,32 @@ fn main() { for plugin in FIXTURE_PLUGINS { let plugin_dir = root.join(plugin.dir); - let wasm_path = plugin_dir - .join("target/wasm32-unknown-unknown/release") - .join(plugin.wasm); + let release_dir = plugin_dir.join("target/wasm32-unknown-unknown/release"); + let wasm_path = release_dir.join(plugin.wasm); - // Re-run if any source file in the plugin changes. + // Re-run if any source file or the manifest changes. println!("cargo:rerun-if-changed={}/src", plugin_dir.display()); println!("cargo:rerun-if-changed={}/Cargo.toml", plugin_dir.display()); + println!("cargo:rerun-if-changed={}/plugin.toml", plugin_dir.display()); - if wasm_path.exists() { - // Already built — skip (incremental builds use rerun-if-changed above). - continue; + if !wasm_path.exists() { + build_fixture_plugin(&plugin_dir, &wasm_path, plugin.wasm); } - build_fixture_plugin(&plugin_dir, &wasm_path, plugin.wasm); + // The compiler reads a plugin's capabilities from a `plugin.toml` in the + // same directory as its .wasm. These fixtures keep plugin.toml at the + // crate root, so copy it next to the built wasm — otherwise capability + // enforcement sees no declared host functions and rejects the plugin. + let src_toml = plugin_dir.join("plugin.toml"); + let dst_toml = release_dir.join("plugin.toml"); + if src_toml.exists() && release_dir.exists() { + if let Err(e) = std::fs::copy(&src_toml, &dst_toml) { + println!( + "cargo:warning=failed to copy {} plugin.toml next to wasm: {}", + plugin.wasm, e + ); + } + } } } diff --git a/crates/barbacane-test/src/gateway.rs b/crates/barbacane-test/src/gateway.rs index f82b29d..a725d56 100644 --- a/crates/barbacane-test/src/gateway.rs +++ b/crates/barbacane-test/src/gateway.rs @@ -108,24 +108,33 @@ impl TestGateway { spec_path: &str, extra_args: &[&str], ) -> Result { - Self::create_gateway_with_args(&[spec_path], false, extra_args, true).await + Self::create_gateway_with_args(&[spec_path], false, extra_args, true, &[]).await + } + + /// Create a TestGateway with extra environment variables set on the data-plane + /// child process (e.g. `BARBACANE_SECRETS_DIR`). + pub async fn from_spec_with_env( + spec_path: &str, + env: &[(&str, &str)], + ) -> Result { + Self::create_gateway_with_args(&[spec_path], false, &[], true, env).await } /// Create a TestGateway with the plugin SSRF guard ACTIVE (internal egress /// blocked). Use this for SSRF tests; the default constructors allow internal /// egress so tests can reach loopback mock upstreams. pub async fn from_spec_blocked_egress(spec_path: &str) -> Result { - Self::create_gateway_with_args(&[spec_path], false, &[], false).await + Self::create_gateway_with_args(&[spec_path], false, &[], false, &[]).await } /// Create a TestGateway from multiple spec files. pub async fn from_specs(spec_paths: &[&str]) -> Result { - Self::create_gateway_with_args(spec_paths, false, &[], true).await + Self::create_gateway_with_args(spec_paths, false, &[], true, &[]).await } /// Create a TLS-enabled TestGateway from multiple spec files. pub async fn from_specs_with_tls(spec_paths: &[&str]) -> Result { - Self::create_gateway_with_args(spec_paths, true, &[], true).await + Self::create_gateway_with_args(spec_paths, true, &[], true, &[]).await } /// Internal method to create a gateway with optional TLS and extra CLI args. @@ -138,6 +147,7 @@ impl TestGateway { tls_enabled: bool, extra_args: &[&str], allow_internal_egress: bool, + env: &[(&str, &str)], ) -> Result { // Create temp directory for the artifact let temp_dir = TempDir::new()?; @@ -218,6 +228,13 @@ impl TestGateway { cmd.arg(arg); } + // Inject per-instance environment variables (e.g. BARBACANE_SECRETS_DIR) + // into the child process, avoiding process-global set_var races between + // concurrently running tests. + for (key, value) in env { + cmd.env(key, value); + } + // Start the gateway process let child = cmd.spawn()?; diff --git a/crates/barbacane-test/tests/auth.rs b/crates/barbacane-test/tests/auth.rs index baa5fdf..b1b4c49 100644 --- a/crates/barbacane-test/tests/auth.rs +++ b/crates/barbacane-test/tests/auth.rs @@ -18,12 +18,98 @@ fn fixture(name: &str) -> String { // JWT Authentication Tests -/// Helper to create a JWT token for testing. -/// Creates unsigned tokens since we use skip_signature_validation: true in tests. +/// Absolute path to a plugin's built wasm. +fn plugin_wasm(name: &str) -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join(format!("plugins/{name}/{name}.wasm")) +} + +/// Fixed P-256 signing key for test JWTs. Deterministic so the derived public +/// JWK embedded in the spec stays stable across runs. +fn jwt_signing_key() -> p256::ecdsa::SigningKey { + // A fixed, non-zero scalar well below the curve order. + let scalar = [0x11u8; 32]; + p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar") +} + +/// The public key of [`jwt_signing_key`] as ES256 JWK coordinates (base64url). +fn jwt_public_jwk_xy() -> (String, String) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + let sk = jwt_signing_key(); + let point = sk.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().expect("x coord").as_slice()); + let y = URL_SAFE_NO_PAD.encode(point.y().expect("y coord").as_slice()); + (x, y) +} + +/// Build a temp spec whose `/protected` route requires jwt-auth verifying tokens +/// signed by [`jwt_signing_key`]. The plugin enforces the signature in the +/// production WASM (skip_signature_validation is honored only under the plugin's +/// own cfg(test) unit tests), so the spec embeds the matching public JWK. +fn jwt_auth_spec() -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().expect("temp dir"); + let (x, y) = jwt_public_jwk_xy(); + let spec = format!( + r#"openapi: "3.0.3" +info: + title: JWT Auth Test API + version: "1.0.0" +x-barbacane-middlewares: + - name: jwt-auth + config: + issuer: "test-issuer" + audience: "test-audience" + clock_skew_seconds: 60 + public_key_jwk: + kty: "EC" + crv: "P-256" + alg: "ES256" + x: "{x}" + y: "{y}" +paths: + /protected: + get: + operationId: getProtected + x-barbacane-dispatch: + name: mock + config: + status: 200 + body: '{{"message": "Access granted"}}' + content_type: application/json + responses: + "200": + description: Success + "401": + description: Unauthorized +"#, + x = x, + y = y + ); + std::fs::write(temp.path().join("jwt-auth.yaml"), spec).expect("write spec"); + std::fs::write( + temp.path().join("barbacane.yaml"), + format!( + "plugins:\n mock:\n path: {mock}\n jwt-auth:\n path: {jwt}\n", + mock = plugin_wasm("mock").display(), + jwt = plugin_wasm("jwt-auth").display(), + ), + ) + .expect("write manifest"); + let path = temp.path().join("jwt-auth.yaml"); + (temp, path) +} + +/// Create a signed ES256 JWT for testing, verifiable against [`jwt_auth_spec`]. fn create_test_jwt(sub: &str, iss: &str, aud: &str, exp: u64, nbf: Option) -> String { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use p256::ecdsa::{signature::Signer, Signature}; - let header = serde_json::json!({"alg": "RS256", "typ": "JWT"}); + let header = serde_json::json!({"alg": "ES256", "typ": "JWT"}); let mut claims = serde_json::json!({ "sub": sub, "iss": iss, @@ -36,10 +122,14 @@ fn create_test_jwt(sub: &str, iss: &str, aud: &str, exp: u64, nbf: Option) let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes()); - // Signature is just filler since we skip validation - let sig_b64 = URL_SAFE_NO_PAD.encode(b"test_signature"); + let signing_input = format!("{}.{}", header_b64, claims_b64); + + // ES256 signature: raw r||s (64 bytes), matching the host's FIXED-format + // ECDSA_P256_SHA256 verification. + let signature: Signature = jwt_signing_key().sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); - format!("{}.{}.{}", header_b64, claims_b64, sig_b64) + format!("{}.{}", signing_input, sig_b64) } /// Get current Unix timestamp. @@ -105,7 +195,8 @@ async fn test_jwt_auth_malformed_token() { #[tokio::test] async fn test_jwt_auth_valid_token() { - let gateway = TestGateway::from_spec(&fixture("jwt-auth.yaml")) + let (_tmp, spec) = jwt_auth_spec(); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); @@ -127,7 +218,8 @@ async fn test_jwt_auth_valid_token() { #[tokio::test] async fn test_jwt_auth_expired_token() { - let gateway = TestGateway::from_spec(&fixture("jwt-auth.yaml")) + let (_tmp, spec) = jwt_auth_spec(); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); @@ -150,7 +242,8 @@ async fn test_jwt_auth_expired_token() { #[tokio::test] async fn test_jwt_auth_not_yet_valid() { - let gateway = TestGateway::from_spec(&fixture("jwt-auth.yaml")) + let (_tmp, spec) = jwt_auth_spec(); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); @@ -176,7 +269,8 @@ async fn test_jwt_auth_not_yet_valid() { #[tokio::test] async fn test_jwt_auth_invalid_issuer() { - let gateway = TestGateway::from_spec(&fixture("jwt-auth.yaml")) + let (_tmp, spec) = jwt_auth_spec(); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); @@ -198,7 +292,8 @@ async fn test_jwt_auth_invalid_issuer() { #[tokio::test] async fn test_jwt_auth_invalid_audience() { - let gateway = TestGateway::from_spec(&fixture("jwt-auth.yaml")) + let (_tmp, spec) = jwt_auth_spec(); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); @@ -879,10 +974,18 @@ async fn test_secrets_file_reference_resolved() { let introspection_url = format!("{}/introspect", mock_server.uri()); let spec_path = create_oauth2_secrets_spec(temp_dir.path(), &introspection_url, &secret_ref); - // Gateway should start successfully with the resolved secret - let gateway = TestGateway::from_spec(spec_path.to_str().unwrap()) - .await - .expect("failed to start gateway with file secret"); + // Gateway should start successfully with the resolved secret. file:// + // references are confined to BARBACANE_SECRETS_DIR (fail-closed hardening), + // so point it at the temp dir holding the secret file. + let gateway = TestGateway::from_spec_with_env( + spec_path.to_str().unwrap(), + &[( + "BARBACANE_SECRETS_DIR", + temp_dir.path().to_str().unwrap(), + )], + ) + .await + .expect("failed to start gateway with file secret"); // Make a request with a valid token let resp = gateway diff --git a/crates/barbacane-test/tests/mcp.rs b/crates/barbacane-test/tests/mcp.rs index 9a4779f..0e8b3da 100644 --- a/crates/barbacane-test/tests/mcp.rs +++ b/crates/barbacane-test/tests/mcp.rs @@ -342,12 +342,22 @@ async fn test_mcp_invalid_json() { async fn test_mcp_unknown_method() { let gw = mcp_gateway().await; - let resp = mcp_post( + // A session is required before any non-initialize method; without it the + // request is rejected as invalid (-32600) before method dispatch. + let init = mcp_post( &gw, - r#"{"jsonrpc":"2.0","id":1,"method":"resources/list"}"#, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"test","version":"1.0"}}}"#, None, ) .await; + let session_id = extract_session_id(&init); + + let resp = mcp_post( + &gw, + r#"{"jsonrpc":"2.0","id":2,"method":"resources/list"}"#, + Some(&session_id), + ) + .await; assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); diff --git a/crates/barbacane-test/tests/plugins.rs b/crates/barbacane-test/tests/plugins.rs index 1c7af13..19c8c46 100644 --- a/crates/barbacane-test/tests/plugins.rs +++ b/crates/barbacane-test/tests/plugins.rs @@ -1177,17 +1177,27 @@ async fn test_response_transformer_passthrough() { // Redirect middleware integration tests // ========================================================================= +/// GET a path without following redirects, so the 3xx status + Location header +/// can be asserted directly. The shared TestGateway client follows redirects, +/// which would turn a 301 -> /new-page into a 404 for the nonexistent target. +async fn get_no_redirect(gateway: &TestGateway, path: &str) -> reqwest::Response { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build no-redirect client") + .get(format!("{}{}", gateway.base_url(), path)) + .send() + .await + .expect("request failed") +} + #[tokio::test] async fn test_redirect_exact_path_301() { let gateway = TestGateway::from_spec(&fixture("redirect.yaml")) .await .expect("failed to start gateway"); - let resp = gateway - .request_builder(reqwest::Method::GET, "/old-page") - .send() - .await - .unwrap(); + let resp = get_no_redirect(&gateway, "/old-page").await; assert_eq!(resp.status(), 301); assert_eq!( @@ -1202,11 +1212,7 @@ async fn test_redirect_prefix_strips_and_appends() { .await .expect("failed to start gateway"); - let resp = gateway - .request_builder(reqwest::Method::GET, "/api/v1/users") - .send() - .await - .unwrap(); + let resp = get_no_redirect(&gateway, "/api/v1/users").await; assert_eq!(resp.status(), 302); assert_eq!( @@ -1221,11 +1227,7 @@ async fn test_redirect_catch_all_308() { .await .expect("failed to start gateway"); - let resp = gateway - .request_builder(reqwest::Method::GET, "/catch-all") - .send() - .await - .unwrap(); + let resp = get_no_redirect(&gateway, "/catch-all").await; assert_eq!(resp.status(), 308); assert_eq!( @@ -1240,11 +1242,7 @@ async fn test_redirect_preserves_query_string() { .await .expect("failed to start gateway"); - let resp = gateway - .request_builder(reqwest::Method::GET, "/with-query?foo=bar&page=2") - .send() - .await - .unwrap(); + let resp = get_no_redirect(&gateway, "/with-query?foo=bar&page=2").await; assert_eq!(resp.status(), 302); assert_eq!( @@ -1259,11 +1257,7 @@ async fn test_redirect_strips_query_when_disabled() { .await .expect("failed to start gateway"); - let resp = gateway - .request_builder(reqwest::Method::GET, "/no-query?foo=bar") - .send() - .await - .unwrap(); + let resp = get_no_redirect(&gateway, "/no-query?foo=bar").await; assert_eq!(resp.status(), 302); assert_eq!( diff --git a/crates/barbacane-test/tests/proxy.rs b/crates/barbacane-test/tests/proxy.rs index ca76dcf..d1d969c 100644 --- a/crates/barbacane-test/tests/proxy.rs +++ b/crates/barbacane-test/tests/proxy.rs @@ -35,16 +35,96 @@ fn plugin_wasm(name: &str) -> PathBuf { // M4: HTTP Upstream Tests // ======================== +/// Build a temp spec whose http-upstream routes proxy to a local wiremock, +/// keeping these tests hermetic. (They previously hit the real httpbin.org, +/// which is non-deterministic in CI and, when slow, trips the plugin's +/// wall-clock deadline and surfaces as a 500.) +fn http_upstream_spec(upstream_url: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().expect("temp dir"); + let spec = format!( + r#"openapi: "3.1.0" +info: + title: HTTP Upstream Test API + version: "1.0.0" +paths: + /proxy/get: + get: + operationId: proxyGet + x-barbacane-dispatch: + name: http-upstream + config: + url: "{url}" + path: "/get" + timeout: 10.0 + responses: + "200": + description: OK + /proxy/post: + post: + operationId: proxyPost + requestBody: + required: false + content: + application/json: + schema: + type: object + x-barbacane-dispatch: + name: http-upstream + config: + url: "{url}" + path: "/post" + timeout: 10.0 + responses: + "200": + description: OK + /proxy/headers: + get: + operationId: proxyHeaders + x-barbacane-dispatch: + name: http-upstream + config: + url: "{url}" + path: "/headers" + timeout: 10.0 + responses: + "200": + description: OK +"#, + url = upstream_url + ); + std::fs::write(temp.path().join("http-upstream.yaml"), spec).expect("write spec"); + std::fs::write( + temp.path().join("barbacane.yaml"), + format!( + "plugins:\n http-upstream:\n path: {}\n", + plugin_wasm("http-upstream").display() + ), + ) + .expect("write manifest"); + let path = temp.path().join("http-upstream.yaml"); + (temp, path) +} + #[tokio::test] async fn test_http_upstream_get() { - let gateway = TestGateway::from_spec(&fixture("http-upstream.yaml")) + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/get")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "url": format!("{}/get", upstream.uri()), + }))) + .mount(&upstream) + .await; + + let (_tmp, spec) = http_upstream_spec(&upstream.uri()); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); - // Proxy GET request to httpbin.org/get let resp = gateway.get("/proxy/get").await.unwrap(); - - // httpbin.org/get returns 200 with JSON containing request details assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); @@ -56,23 +136,32 @@ async fn test_http_upstream_get() { #[tokio::test] async fn test_http_upstream_post() { - let gateway = TestGateway::from_spec(&fixture("http-upstream.yaml")) + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let upstream = MockServer::start().await; + // Echo the posted JSON back under `json`, like httpbin's /post. + Mock::given(method("POST")) + .and(path("/post")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "json": {"test": "data"}, + }))) + .mount(&upstream) + .await; + + let (_tmp, spec) = http_upstream_spec(&upstream.uri()); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); - // Proxy POST request to httpbin.org/post let resp = gateway .post("/proxy/post", r#"{"test":"data"}"#) .await .unwrap(); - // httpbin.org/post returns 200 with JSON containing request details let status = resp.status(); let body_text = resp.text().await.unwrap(); - assert_eq!( - status, 200, - "expected 200, got {status}. Body:\n{body_text}" - ); + assert_eq!(status, 200, "expected 200, got {status}. Body:\n{body_text}"); let body: serde_json::Value = serde_json::from_str(&body_text).unwrap(); assert!( @@ -84,18 +173,31 @@ async fn test_http_upstream_post() { #[tokio::test] async fn test_http_upstream_headers_forwarded() { - let gateway = TestGateway::from_spec(&fixture("http-upstream.yaml")) + use wiremock::matchers::{header_exists, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let upstream = MockServer::start().await; + // Only match when the gateway actually forwarded X-Forwarded-Host; if it + // didn't, wiremock returns no match and the request 404s, failing the test. + Mock::given(method("GET")) + .and(path("/headers")) + .and(header_exists("x-forwarded-host")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "headers": {"X-Forwarded-Host": "gateway"}, + }))) + .mount(&upstream) + .await; + + let (_tmp, spec) = http_upstream_spec(&upstream.uri()); + let gateway = TestGateway::from_spec(spec.to_str().unwrap()) .await .expect("failed to start gateway"); - // httpbin.org/headers returns the request headers let resp = gateway.get("/proxy/headers").await.unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); let headers = &body["headers"]; - - // Should have X-Forwarded-Host header assert!( headers.get("X-Forwarded-Host").is_some() || headers.get("x-forwarded-host").is_some(), "should forward X-Forwarded-Host header" diff --git a/tests/fixture-plugins/streaming-echo/plugin.toml b/tests/fixture-plugins/streaming-echo/plugin.toml index 7b902a4..add5fb8 100644 --- a/tests/fixture-plugins/streaming-echo/plugin.toml +++ b/tests/fixture-plugins/streaming-echo/plugin.toml @@ -6,5 +6,6 @@ description = "Test fixture: proxies to upstream via host_http_stream (ADR-0023 wasm = "streaming_echo.wasm" [capabilities] -http_stream = true -log = true +# host_http_stream (ADR-0023) is granted by the http_call capability, and the +# manifest reads the `host_functions` array (not `key = true` shorthand). +host_functions = ["http_call"] diff --git a/tests/fixtures/ip-restriction.yaml b/tests/fixtures/ip-restriction.yaml index 2f6ea78..8ec881e 100644 --- a/tests/fixtures/ip-restriction.yaml +++ b/tests/fixtures/ip-restriction.yaml @@ -11,6 +11,12 @@ paths: x-barbacane-middlewares: - name: ip-restriction config: + # The test client connects over loopback, so trust it as a proxy; + # otherwise X-Forwarded-For is ignored (anti-spoofing default) and + # the XFF-based cases below can't be exercised. + trusted_proxies: + - "127.0.0.1" + - "::1" allow: - "127.0.0.1" - "::1" @@ -32,6 +38,9 @@ paths: x-barbacane-middlewares: - name: ip-restriction config: + trusted_proxies: + - "127.0.0.1" + - "::1" deny: - "10.0.0.0/8" - "192.168.0.0/16" From f7d28f6c0c21df858bb92e6d596309acdcf07c7c Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 3 Jul 2026 10:39:21 +0200 Subject: [PATCH 7/8] style: cargo fmt --- crates/barbacane-test/build.rs | 5 ++++- crates/barbacane-test/tests/auth.rs | 5 +---- crates/barbacane-test/tests/proxy.rs | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/barbacane-test/build.rs b/crates/barbacane-test/build.rs index 0721a5e..bc67741 100644 --- a/crates/barbacane-test/build.rs +++ b/crates/barbacane-test/build.rs @@ -54,7 +54,10 @@ fn main() { // Re-run if any source file or the manifest changes. println!("cargo:rerun-if-changed={}/src", plugin_dir.display()); println!("cargo:rerun-if-changed={}/Cargo.toml", plugin_dir.display()); - println!("cargo:rerun-if-changed={}/plugin.toml", plugin_dir.display()); + println!( + "cargo:rerun-if-changed={}/plugin.toml", + plugin_dir.display() + ); if !wasm_path.exists() { build_fixture_plugin(&plugin_dir, &wasm_path, plugin.wasm); diff --git a/crates/barbacane-test/tests/auth.rs b/crates/barbacane-test/tests/auth.rs index b1b4c49..8d3195f 100644 --- a/crates/barbacane-test/tests/auth.rs +++ b/crates/barbacane-test/tests/auth.rs @@ -979,10 +979,7 @@ async fn test_secrets_file_reference_resolved() { // so point it at the temp dir holding the secret file. let gateway = TestGateway::from_spec_with_env( spec_path.to_str().unwrap(), - &[( - "BARBACANE_SECRETS_DIR", - temp_dir.path().to_str().unwrap(), - )], + &[("BARBACANE_SECRETS_DIR", temp_dir.path().to_str().unwrap())], ) .await .expect("failed to start gateway with file secret"); diff --git a/crates/barbacane-test/tests/proxy.rs b/crates/barbacane-test/tests/proxy.rs index d1d969c..14dc959 100644 --- a/crates/barbacane-test/tests/proxy.rs +++ b/crates/barbacane-test/tests/proxy.rs @@ -161,7 +161,10 @@ async fn test_http_upstream_post() { let status = resp.status(); let body_text = resp.text().await.unwrap(); - assert_eq!(status, 200, "expected 200, got {status}. Body:\n{body_text}"); + assert_eq!( + status, 200, + "expected 200, got {status}. Body:\n{body_text}" + ); let body: serde_json::Value = serde_json::from_str(&body_text).unwrap(); assert!( From abde1fbe8c22373376e5796c9122786f76892d00 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 3 Jul 2026 11:01:46 +0200 Subject: [PATCH 8/8] test(ip-restriction): use dedicated trusted-proxy endpoints for XFF cases The previous fix added trusted_proxies to /allowlist and /denylist in the shared ip-restriction.yaml fixture, which broke the security suite's forged_xff_does_not_bypass_ip_restriction test: that test forges an XFF on /denylist and asserts it is ignored (anti-spoofing), which only holds when trusted_proxies is empty. Revert /allowlist and /denylist to their (empty trusted_proxies) defaults so the anti-spoofing guarantee is preserved, and add separate /allowlist-trusted-proxy and /denylist-trusted-proxy endpoints that trust the loopback peer. The plugins.rs XFF tests use those, so they exercise the 'honored when behind a trusted proxy' path without weakening the endpoints the security suite depends on. --- crates/barbacane-test/tests/plugins.rs | 12 +++-- tests/fixtures/ip-restriction.yaml | 61 ++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/crates/barbacane-test/tests/plugins.rs b/crates/barbacane-test/tests/plugins.rs index 19c8c46..d7020cf 100644 --- a/crates/barbacane-test/tests/plugins.rs +++ b/crates/barbacane-test/tests/plugins.rs @@ -501,9 +501,12 @@ async fn test_ip_restriction_allowlist_denied_via_xff() { .await .expect("failed to start gateway"); - // Request with X-Forwarded-For from non-allowed IP should be denied + // X-Forwarded-For from a non-allowed IP should be denied — but only because + // this endpoint trusts the loopback proxy, so the forwarded address is + // honored. (/allowlist leaves trusted_proxies empty so a forged XFF is + // ignored; that anti-spoofing case is covered in the security suite.) let resp = gateway - .request_builder(reqwest::Method::GET, "/allowlist") + .request_builder(reqwest::Method::GET, "/allowlist-trusted-proxy") .header("X-Forwarded-For", "203.0.113.50") .send() .await @@ -536,9 +539,10 @@ async fn test_ip_restriction_denylist_blocked() { .await .expect("failed to start gateway"); - // Request from denied CIDR range should be blocked + // XFF in the denied CIDR range should be blocked on the trusted-proxy + // endpoint (which honors X-Forwarded-For from the loopback peer). let resp = gateway - .request_builder(reqwest::Method::GET, "/denylist") + .request_builder(reqwest::Method::GET, "/denylist-trusted-proxy") .header("X-Forwarded-For", "10.1.2.3") .send() .await diff --git a/tests/fixtures/ip-restriction.yaml b/tests/fixtures/ip-restriction.yaml index 8ec881e..821ebc9 100644 --- a/tests/fixtures/ip-restriction.yaml +++ b/tests/fixtures/ip-restriction.yaml @@ -11,12 +11,6 @@ paths: x-barbacane-middlewares: - name: ip-restriction config: - # The test client connects over loopback, so trust it as a proxy; - # otherwise X-Forwarded-For is ignored (anti-spoofing default) and - # the XFF-based cases below can't be exercised. - trusted_proxies: - - "127.0.0.1" - - "::1" allow: - "127.0.0.1" - "::1" @@ -38,9 +32,6 @@ paths: x-barbacane-middlewares: - name: ip-restriction config: - trusted_proxies: - - "127.0.0.1" - - "::1" deny: - "10.0.0.0/8" - "192.168.0.0/16" @@ -97,6 +88,58 @@ paths: "401": description: Unauthorized + # Endpoints that trust the loopback peer as a proxy, so X-Forwarded-For is + # honored. Kept separate from /allowlist and /denylist, which deliberately + # leave trusted_proxies empty so the security suite can verify that a forged + # XFF is ignored (anti-spoofing). See tests/security/crypto_auth.rs. + /allowlist-trusted-proxy: + get: + operationId: allowlistTrustedProxyEndpoint + summary: IP allowlist that honors XFF from the trusted loopback proxy + x-barbacane-middlewares: + - name: ip-restriction + config: + trusted_proxies: + - "127.0.0.1" + - "::1" + allow: + - "127.0.0.1" + - "::1" + x-barbacane-dispatch: + name: mock + config: + status: 200 + body: '{"message":"allowed"}' + responses: + "200": + description: Success + "403": + description: Forbidden + + /denylist-trusted-proxy: + get: + operationId: denylistTrustedProxyEndpoint + summary: IP denylist that honors XFF from the trusted loopback proxy + x-barbacane-middlewares: + - name: ip-restriction + config: + trusted_proxies: + - "127.0.0.1" + - "::1" + deny: + - "10.0.0.0/8" + - "192.168.0.0/16" + x-barbacane-dispatch: + name: mock + config: + status: 200 + body: '{"message":"allowed"}' + responses: + "200": + description: Success + "403": + description: Forbidden + /public: get: operationId: publicEndpoint