diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f010de9..94b9424 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 @@ -288,8 +294,20 @@ 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" + # --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 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..bc67741 100644 --- a/crates/barbacane-test/build.rs +++ b/crates/barbacane-test/build.rs @@ -48,20 +48,35 @@ 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 9081631..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()?; @@ -261,9 +278,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 { 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 diff --git a/crates/barbacane-test/tests/auth.rs b/crates/barbacane-test/tests/auth.rs index baa5fdf..8d3195f 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,15 @@ 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..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 @@ -1177,17 +1181,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 +1216,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 +1231,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 +1246,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 +1261,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..14dc959 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,17 +136,29 @@ 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!( @@ -84,18 +176,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/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), 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..821ebc9 100644 --- a/tests/fixtures/ip-restriction.yaml +++ b/tests/fixtures/ip-restriction.yaml @@ -88,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