diff --git a/docs/filters/identity_header_guard.md b/docs/filters/identity_header_guard.md new file mode 100644 index 000000000..7a54ea305 --- /dev/null +++ b/docs/filters/identity_header_guard.md @@ -0,0 +1,25 @@ + + + +# `identity_header_guard` + +Captures request headers matching a configured prefix into `filter_metadata` and removes them from the upstream request. + +## Configuration Notes + +A client that sets `x-tenant-username: admin` directly is indistinguishable from a gateway that set it legitimately unless this filter strips the headers first. Place it early in the pipeline — before any filter that reads identity from request headers. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `prefix` | string | yes | Case-insensitive header name prefix to capture and strip. | +| `metadata_namespace` | string | no | Metadata namespace for captured headers. Headers are stored as `{namespace}.{header_name}`. | + +## Example + +```yaml +filter: identity_header_guard +prefix: "x-tenant-" +metadata_namespace: "identity" +``` diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 23ce30f4d..faa9f67f3 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -60,6 +60,12 @@ see the [Praxis core filter reference][core-ref]. |--------|-------------| | [`ai_guardrails`](ai_guardrails.md) | Calls an external AI guardrail provider to evaluate request (and eventually response) bodies. The provider determines whether content should be passed, blocked, or redacted. | +### Identity Guard + +| Filter | Description | +|--------|-------------| +| [`identity_header_guard`](identity_header_guard.md) | Captures request headers matching a configured prefix into `filter_metadata` and removes them from the upstream request. | + ### Inference | Filter | Description | diff --git a/examples/README.md b/examples/README.md index 6cf05adfb..df17b46bd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ before sending requests. | [a2a-task-routing.yaml](configs/a2a-task-routing.yaml) | Captures task and context ownership from SendMessage JSON responses and SendStreamingMessage / SubscribeToTask SSE responses, then routes follow-up requests back to the backend cluster that created the task or owns the context | | [ai-inference-body-based-routing.yaml](configs/ai-inference-body-based-routing.yaml) | Routes LLM API requests to different backends based on the `model` field in the JSON request body | | [credential-injection.yaml](configs/credential-injection.yaml) | Injects per-cluster API credentials into upstream requests and strips client-provided credentials to prevent forwarding | +| [identity-header-guard.yaml](configs/identity-header-guard.yaml) | Captures identity headers matching a prefix into filter metadata and strips them before forwarding upstream | | [intelligent-route-all-capabilities.yaml](configs/intelligent-route-all-capabilities.yaml) | Demonstrates every candidate capability and selection input handled by intelligent_route today | | [intelligent-route-inference.yaml](configs/intelligent-route-inference.yaml) | Routes requests to different upstream clusters based on the inference model name extracted from a configured request header. The header value is set by an earlier filter such as `json_body_field` | | [intelligent-route-mcp.yaml](configs/intelligent-route-mcp.yaml) | Routes MCP `tools/call` requests to the cluster that owns the requested tool, using the `mcp.name` metadata set by the `mcp` filter | diff --git a/examples/configs/identity-header-guard.yaml b/examples/configs/identity-header-guard.yaml new file mode 100644 index 000000000..fcf2baeaa --- /dev/null +++ b/examples/configs/identity-header-guard.yaml @@ -0,0 +1,38 @@ +# Identity Header Guard +# +# Captures identity headers matching a prefix into filter +# metadata and strips them before forwarding upstream. +# Prevents identity leakage to LLM providers. +# +# Usage: +# cargo run -p praxis-ai-proxy -- -c examples/configs/identity-header-guard.yaml +# curl http://localhost:8080/v1/chat/completions \ +# -H "x-tenant-username: yossi" \ +# -H "x-tenant-group: ai-eng" \ +# -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}' +# +# The upstream backend receives the request WITHOUT the +# x-tenant-* headers. Identity is available in filter_metadata +# for downstream filters (metering, audit). + +listeners: + - name: gateway + address: "127.0.0.1:8080" + filter_chains: + - guarded + +filter_chains: + - name: guarded + filters: + - filter: identity_header_guard + prefix: "x-tenant-" + metadata_namespace: "identity" + - filter: router + routes: + - path_prefix: "/" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:3000" diff --git a/filters/src/identity_guard/config.rs b/filters/src/identity_guard/config.rs new file mode 100644 index 000000000..47b777aac --- /dev/null +++ b/filters/src/identity_guard/config.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Configuration for the identity header guard filter. + +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// IdentityHeaderGuardConfig +// ----------------------------------------------------------------------------- + +/// Deserialized YAML config for the identity header guard filter. +/// +/// ```yaml +/// filter: identity_header_guard +/// prefix: "x-tenant-" +/// metadata_namespace: "identity" +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct IdentityHeaderGuardConfig { + /// Case-insensitive header name prefix to capture and strip. + pub prefix: String, + + /// Metadata namespace for captured headers. + /// Headers are stored as `{namespace}.{header_name}`. + #[serde(default = "default_namespace")] + pub metadata_namespace: String, +} + +/// Returns the default metadata namespace (`identity`). +fn default_namespace() -> String { + "identity".to_owned() +} + +// ----------------------------------------------------------------------------- +// Validation +// ----------------------------------------------------------------------------- + +/// Validate an [`IdentityHeaderGuardConfig`], returning an error on missing required fields. +pub(super) fn validate_config(config: &IdentityHeaderGuardConfig) -> Result<(), String> { + if config.prefix.is_empty() { + return Err("identity_header_guard: prefix must not be empty".into()); + } + if config.metadata_namespace.is_empty() { + return Err("identity_header_guard: metadata_namespace must not be empty".into()); + } + Ok(()) +} diff --git a/filters/src/identity_guard/mod.rs b/filters/src/identity_guard/mod.rs new file mode 100644 index 000000000..f32d1df0d --- /dev/null +++ b/filters/src/identity_guard/mod.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Identity header guard filter: captures request headers matching +//! a configured prefix into `filter_metadata` and strips them from +//! the upstream request. +//! +//! Prevents identity headers injected by a trusted auth layer +//! (e.g. `x-tenant-username`) from leaking to upstream LLM +//! providers, while making them available to downstream filters +//! (metering, audit) via metadata. +//! +//! Maps to IPP's `maas-headers-guard` plugin but is generic: +//! the prefix is configurable rather than hardcoded to `x-maas-`. + +mod config; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used, reason = "tests")] +mod tests; + +use async_trait::async_trait; +use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext, parse_filter_config}; +use tracing::trace; + +use self::config::{IdentityHeaderGuardConfig, validate_config}; + +// ----------------------------------------------------------------------------- +// IdentityHeaderGuardFilter +// ----------------------------------------------------------------------------- + +/// Captures request headers matching a configured prefix into +/// `filter_metadata` and removes them from the upstream request. +/// +/// A client that sets `x-tenant-username: admin` directly is +/// indistinguishable from a gateway that set it legitimately +/// unless this filter strips the headers first. Place it early +/// in the pipeline — before any filter that reads identity from +/// request headers. +/// +/// # YAML configuration +/// +/// ```yaml +/// filter: identity_header_guard +/// prefix: "x-tenant-" +/// metadata_namespace: "identity" +/// ``` +/// +/// # Example +/// +/// ```rust +/// use praxis_ai_filters::IdentityHeaderGuardFilter; +/// +/// let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); +/// let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); +/// assert_eq!(filter.name(), "identity_header_guard"); +/// ``` +pub struct IdentityHeaderGuardFilter { + /// Lowercase prefix to match against header names. + prefix: String, + + /// Metadata key namespace for captured headers. + namespace: String, +} + +impl IdentityHeaderGuardFilter { + /// Parse from YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if config parsing or validation fails. + pub fn from_config(value: &serde_yaml::Value) -> Result, FilterError> { + let config: IdentityHeaderGuardConfig = parse_filter_config("identity_header_guard", value)?; + validate_config(&config).map_err(|e| -> FilterError { e.into() })?; + + Ok(Box::new(Self { + prefix: config.prefix.to_lowercase(), + namespace: config.metadata_namespace, + })) + } +} + +#[async_trait] +impl HttpFilter for IdentityHeaderGuardFilter { + fn name(&self) -> &'static str { + "identity_header_guard" + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let mut captured = 0_usize; + + for (name, value) in &ctx.request.headers { + let name_lower = name.as_str().to_lowercase(); + + if !name_lower.starts_with(&self.prefix) { + continue; + } + + if let Ok(val) = value.to_str() { + // Namespaced key only. The guard must NOT write + // unnamespaced keys — jwt_auth writes those from + // verified claims, and overwriting them here would + // launder client-spoofed headers into the trusted + // metadata namespace. + // + // First-wins: if the key already exists (from an + // earlier auth filter or a prior header iteration), + // trust the first value. Prevents a client from + // appending a duplicate header to override a + // legitimate value set by an upstream proxy. + let namespaced = format!("{}.{}", self.namespace, name_lower); + if !ctx.filter_metadata.contains_key(&namespaced) { + ctx.set_metadata(namespaced, val.to_owned()); + captured += 1; + } + } + + ctx.request_headers_to_remove.push(name.clone()); + } + + if captured > 0 { + trace!(captured, prefix = %self.prefix, "identity headers captured and stripped"); + } + + Ok(FilterAction::Continue) + } +} diff --git a/filters/src/identity_guard/tests.rs b/filters/src/identity_guard/tests.rs new file mode 100644 index 000000000..456bb58f9 --- /dev/null +++ b/filters/src/identity_guard/tests.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the identity header guard filter. + +use http::{HeaderValue, Method}; +use praxis_filter::FilterAction; + +use super::IdentityHeaderGuardFilter; +use crate::test_utils::{make_filter_context, make_request}; + +// ----------------------------------------------------------------------------- +// Config Tests +// ----------------------------------------------------------------------------- + +#[test] +fn from_config_minimal() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + assert_eq!( + filter.name(), + "identity_header_guard", + "should produce identity_header_guard filter" + ); +} + +#[test] +fn from_config_full() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +prefix: "x-maas-" +metadata_namespace: "maas" +"#, + ) + .unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "identity_header_guard", "full config should parse"); +} + +#[test] +fn from_config_rejects_empty_prefix() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: """#).unwrap(); + match IdentityHeaderGuardFilter::from_config(&yaml) { + Err(err) => assert!( + err.to_string().contains("prefix must not be empty"), + "error should mention prefix: {err}" + ), + Ok(_) => panic!("empty prefix should be rejected"), + } +} + +#[test] +fn from_config_rejects_unknown_fields() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +prefix: "x-tenant-" +bogus_field: true +"#, + ) + .unwrap(); + assert!( + IdentityHeaderGuardFilter::from_config(&yaml).is_err(), + "unknown fields should be rejected" + ); +} + +#[test] +fn from_config_defaults_namespace_to_identity() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "identity_header_guard"); + + // Verify default namespace via the captured metadata key prefix. + // The default namespace is "identity" — custom_namespace test below + // verifies that a non-default value produces "maas." prefixed keys. + // This test verifies the default by checking from_config succeeds + // without metadata_namespace and the filter is usable (the actual + // prefix is verified in captures_matching_headers_to_metadata). +} + +// ----------------------------------------------------------------------------- +// Behavior Tests +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn captures_matching_headers_to_metadata() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/chat/completions"); + req.headers + .insert("x-tenant-username", HeaderValue::from_static("yossi")); + req.headers.insert("x-tenant-group", HeaderValue::from_static("ai-eng")); + + let mut ctx = make_filter_context(&req); + let action = filter.on_request(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue), "should continue the pipeline"); + assert_eq!( + ctx.filter_metadata.get("identity.x-tenant-username"), + Some(&"yossi".to_owned()), + "username should be captured to metadata" + ); + assert_eq!( + ctx.filter_metadata.get("identity.x-tenant-group"), + Some(&"ai-eng".to_owned()), + "group should be captured to metadata" + ); +} + +#[tokio::test] +async fn strips_matching_headers_from_upstream() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/messages"); + req.headers + .insert("x-tenant-username", HeaderValue::from_static("yossi")); + req.headers + .insert("content-type", HeaderValue::from_static("application/json")); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert!( + ctx.request_headers_to_remove + .iter() + .any(|h| h.as_str() == "x-tenant-username"), + "x-tenant-username should be marked for removal" + ); + assert!( + !ctx.request_headers_to_remove + .iter() + .any(|h| h.as_str() == "content-type"), + "content-type should NOT be marked for removal" + ); +} + +#[tokio::test] +async fn ignores_non_matching_headers() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/chat/completions"); + req.headers + .insert("authorization", HeaderValue::from_static("Bearer sk-123")); + req.headers + .insert("content-type", HeaderValue::from_static("application/json")); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert!( + ctx.filter_metadata.is_empty(), + "no identity metadata should be captured" + ); + assert!( + ctx.request_headers_to_remove.is_empty(), + "no headers should be marked for removal" + ); +} + +#[tokio::test] +async fn case_insensitive_prefix_matching() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/chat/completions"); + req.headers + .insert("X-Tenant-Username", HeaderValue::from_static("yossi")); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert_eq!( + ctx.filter_metadata.get("identity.x-tenant-username"), + Some(&"yossi".to_owned()), + "case-insensitive match should capture the header" + ); +} + +#[tokio::test] +async fn custom_namespace() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +prefix: "x-maas-" +metadata_namespace: "maas" +"#, + ) + .unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/messages"); + req.headers.insert("x-maas-username", HeaderValue::from_static("alice")); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert_eq!( + ctx.filter_metadata.get("maas.x-maas-username"), + Some(&"alice".to_owned()), + "should use custom namespace" + ); + assert!( + !ctx.filter_metadata.contains_key("identity.x-maas-username"), + "should NOT use default namespace" + ); +} + +#[tokio::test] +async fn non_utf8_header_stripped_but_not_captured() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/messages"); + req.headers.insert( + "x-tenant-binary", + HeaderValue::from_bytes(&[0x80, 0x81, 0x82]).expect("raw bytes should be valid HeaderValue"), + ); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert!( + ctx.filter_metadata.is_empty(), + "non-UTF-8 values should not be captured to metadata" + ); + assert!( + ctx.request_headers_to_remove + .iter() + .any(|h| h.as_str() == "x-tenant-binary"), + "non-UTF-8 headers should still be stripped for security" + ); +} + +#[tokio::test] +async fn duplicate_headers_last_value_wins() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let mut req = make_request(Method::POST, "/v1/messages"); + req.headers + .append("x-tenant-username", HeaderValue::from_static("admin")); + req.headers + .append("x-tenant-username", HeaderValue::from_static("unprivileged")); + + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + let captured = ctx + .filter_metadata + .get("identity.x-tenant-username") + .expect("should capture the header"); + assert_eq!( + captured, "admin", + "first value should win when duplicate headers are present" + ); +} + +#[tokio::test] +async fn no_headers_means_empty_metadata() { + let yaml: serde_yaml::Value = serde_yaml::from_str(r#"prefix: "x-tenant-""#).unwrap(); + let filter = IdentityHeaderGuardFilter::from_config(&yaml).unwrap(); + + let req = make_request(Method::POST, "/v1/chat/completions"); + let mut ctx = make_filter_context(&req); + let _action = filter.on_request(&mut ctx).await.unwrap(); + + assert!(ctx.filter_metadata.is_empty(), "no identity headers means no metadata"); +} diff --git a/filters/src/lib.rs b/filters/src/lib.rs index 45f0e4b4b..4da7bc90e 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -10,6 +10,7 @@ pub mod agentic; pub mod guardrails; +mod identity_guard; pub mod inference; pub mod prompt_enrich; mod register; @@ -19,6 +20,7 @@ mod token_usage; pub use agentic::{a2a::A2aFilter, mcp::McpFilter}; pub use guardrails::AiGuardrailsFilter; +pub use identity_guard::IdentityHeaderGuardFilter; pub use inference::ModelToHeaderFilter; pub use prompt_enrich::PromptEnrichFilter; pub use register::{build_ai_registry, register_ai_filters}; diff --git a/filters/src/register.rs b/filters/src/register.rs index bcc513805..f2dd34be4 100644 --- a/filters/src/register.rs +++ b/filters/src/register.rs @@ -7,8 +7,8 @@ use praxis_core::subrequest::SubRequestClient; use praxis_filter::FilterRegistry; use crate::{ - A2aFilter, AiGuardrailsFilter, IntelligentRouteFilter, McpFilter, ModelToHeaderFilter, PromptEnrichFilter, - TimeToFirstTokenFilter, TokenCountFilter, TokenUsageHeadersFilter, + A2aFilter, AiGuardrailsFilter, IdentityHeaderGuardFilter, IntelligentRouteFilter, McpFilter, ModelToHeaderFilter, + PromptEnrichFilter, TimeToFirstTokenFilter, TokenCountFilter, TokenUsageHeadersFilter, }; /// Register all in-tree AI HTTP filters into `registry`. @@ -73,6 +73,10 @@ fn register_general_ai_filters(registry: &mut FilterRegistry) { @register registry, http "ai_guardrails" => AiGuardrailsFilter::from_config ); + praxis_filter::register_filters!( + @register registry, + http "identity_header_guard" => IdentityHeaderGuardFilter::from_config + ); praxis_filter::register_filters!( @register registry, http "model_to_header" => ModelToHeaderFilter::from_config @@ -355,6 +359,10 @@ mod tests { names.contains(&"anthropic_web_search"), "expected anthropic_web_search in registry" ); + assert!( + names.contains(&"identity_header_guard"), + "expected identity_header_guard in registry" + ); assert!( names.contains(&"request_id"), "expected core builtin request_id in registry" diff --git a/tests/integration/tests/suite/examples/identity_header_guard.rs b/tests/integration/tests/suite/examples/identity_header_guard.rs new file mode 100644 index 000000000..075b7cdab --- /dev/null +++ b/tests/integration/tests/suite/examples/identity_header_guard.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Tests for the identity header guard example configuration. + +use std::collections::HashMap; + +use praxis_test_utils::{free_port, http_send, parse_body, parse_status, start_header_echo_backend}; + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[test] +fn identity_header_guard_config_parses() { + let config = super::load_example_config( + "identity-header-guard.yaml", + 29920, + HashMap::from([("127.0.0.1:3000", 29921_u16)]), + ); + + assert_eq!(config.listeners.len(), 1, "should have 1 listener"); + assert_eq!(&*config.listeners[0].name, "gateway", "listener name should be gateway"); +} + +#[test] +fn identity_header_guard_strips_identity_headers() { + let backend_guard = start_header_echo_backend(); + let backend_port = backend_guard.port(); + let proxy_port = free_port(); + + let config = super::load_example_config( + "identity-header-guard.yaml", + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_port)]), + ); + + let proxy = praxis_test_utils::start_proxy(&config); + let raw = http_send( + proxy.addr(), + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + x-tenant-username: yossi\r\n\ + x-tenant-group: ai-eng\r\n\ + Connection: close\r\n\r\n", + ); + + assert_eq!(parse_status(&raw), 200, "should return 200"); + let body = parse_body(&raw).to_lowercase(); + assert!( + !body.contains("x-tenant-username"), + "upstream should NOT receive x-tenant-username: {body}" + ); + assert!( + !body.contains("x-tenant-group"), + "upstream should NOT receive x-tenant-group: {body}" + ); +} + +#[test] +fn identity_header_guard_passes_non_matching_headers() { + let backend_guard = start_header_echo_backend(); + let backend_port = backend_guard.port(); + let proxy_port = free_port(); + + let config = super::load_example_config( + "identity-header-guard.yaml", + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_port)]), + ); + + let proxy = praxis_test_utils::start_proxy(&config); + let raw = http_send( + proxy.addr(), + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + x-custom-header: should-pass\r\n\ + Connection: close\r\n\r\n", + ); + + assert_eq!(parse_status(&raw), 200, "should return 200"); + let body = parse_body(&raw).to_lowercase(); + assert!( + body.contains("x-custom-header"), + "upstream should receive non-matching headers: {body}" + ); +} diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index 0524479a1..b26cd5d51 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -16,6 +16,7 @@ mod credential_injection; mod file_search_callout; mod full_flow; mod guardrails; +mod identity_header_guard; mod mcp_broker; mod model_to_header; mod openai_conversations;