From 2b269b6990d1a3eb5bf3eb1329a499327781afe6 Mon Sep 17 00:00:00 2001 From: Yossi Ovadia Date: Tue, 11 Aug 2026 07:46:08 -0700 Subject: [PATCH 1/3] feat(filter): add identity header guard filter Captures request headers matching a configurable prefix into filter_metadata and strips them before upstream forwarding. Prevents identity headers (e.g. x-tenant-username, x-tenant-group) from leaking to LLM providers while making them available to downstream filters like external_metering. Fixes #698 Signed-off-by: Yossi Ovadia --- docs/filters/identity_header_guard.md | 25 ++ docs/filters/reference.md | 6 + examples/README.md | 1 + examples/configs/identity-header-guard.yaml | 38 ++++ filters/src/identity_guard/config.rs | 49 ++++ filters/src/identity_guard/mod.rs | 120 ++++++++++ filters/src/identity_guard/tests.rs | 213 ++++++++++++++++++ filters/src/lib.rs | 2 + filters/src/register.rs | 8 +- .../suite/examples/identity_header_guard.rs | 89 ++++++++ tests/integration/tests/suite/examples/mod.rs | 1 + 11 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 docs/filters/identity_header_guard.md create mode 100644 examples/configs/identity-header-guard.yaml create mode 100644 filters/src/identity_guard/config.rs create mode 100644 filters/src/identity_guard/mod.rs create mode 100644 filters/src/identity_guard/tests.rs create mode 100644 tests/integration/tests/suite/examples/identity_header_guard.rs diff --git a/docs/filters/identity_header_guard.md b/docs/filters/identity_header_guard.md new file mode 100644 index 0000000000..7a54ea305d --- /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 23ce30f4d7..faa9f67f39 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 6cf05adfb1..df17b46bdd 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 0000000000..fcf2baeaae --- /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 0000000000..47b777aacd --- /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 0000000000..83abbd17af --- /dev/null +++ b/filters/src/identity_guard/mod.rs @@ -0,0 +1,120 @@ +// 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. + let namespaced = format!("{}.{}", self.namespace, name_lower); + 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 0000000000..ab24ec7e57 --- /dev/null +++ b/filters/src/identity_guard/tests.rs @@ -0,0 +1,213 @@ +// 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"); +} + +// ----------------------------------------------------------------------------- +// 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 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 45f0e4b4b2..4da7bc90e6 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 bcc513805e..08d1126126 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 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 0000000000..075b7cdabe --- /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 0524479a1a..b26cd5d51d 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; From bcb94098b8356bbe9bdee496b854a18373ab1834 Mon Sep 17 00:00:00 2001 From: Yossi Ovadia Date: Thu, 13 Aug 2026 09:25:19 -0700 Subject: [PATCH 2/3] fix: address praxis-bot review findings - Add registry assertion for identity_header_guard in build_ai_registry_includes_ai_and_builtin_filters test - Add test for non-UTF-8 header values (stripped but not captured) - Add test for duplicate headers (last-value-wins behavior) - Clarify default namespace test with comment explaining the indirect verification via captures_matching_headers_to_metadata Signed-off-by: Yossi Ovadia --- filters/src/identity_guard/tests.rs | 57 +++++++++++++++++++++++++++++ filters/src/register.rs | 8 +++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/filters/src/identity_guard/tests.rs b/filters/src/identity_guard/tests.rs index ab24ec7e57..2b07c71831 100644 --- a/filters/src/identity_guard/tests.rs +++ b/filters/src/identity_guard/tests.rs @@ -69,6 +69,13 @@ 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). } // ----------------------------------------------------------------------------- @@ -200,6 +207,56 @@ metadata_namespace: "maas" ); } +#[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, "unprivileged", + "last 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(); diff --git a/filters/src/register.rs b/filters/src/register.rs index 08d1126126..f2dd34be4c 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, IdentityHeaderGuardFilter, 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`. @@ -359,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" From ea025540aa0bb8bc405ecfab798a7ae8411867d5 Mon Sep 17 00:00:00 2001 From: Yossi Ovadia Date: Thu, 13 Aug 2026 11:49:46 -0700 Subject: [PATCH 3/3] fix: use first-wins for duplicate header capture (security hardening) Signed-off-by: Yossi Ovadia --- filters/src/identity_guard/mod.rs | 12 ++++++++++-- filters/src/identity_guard/tests.rs | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/filters/src/identity_guard/mod.rs b/filters/src/identity_guard/mod.rs index 83abbd17af..f32d1df0d2 100644 --- a/filters/src/identity_guard/mod.rs +++ b/filters/src/identity_guard/mod.rs @@ -103,9 +103,17 @@ impl HttpFilter for IdentityHeaderGuardFilter { // 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); - ctx.set_metadata(namespaced, val.to_owned()); - captured += 1; + if !ctx.filter_metadata.contains_key(&namespaced) { + ctx.set_metadata(namespaced, val.to_owned()); + captured += 1; + } } ctx.request_headers_to_remove.push(name.clone()); diff --git a/filters/src/identity_guard/tests.rs b/filters/src/identity_guard/tests.rs index 2b07c71831..456bb58f9e 100644 --- a/filters/src/identity_guard/tests.rs +++ b/filters/src/identity_guard/tests.rs @@ -252,8 +252,8 @@ async fn duplicate_headers_last_value_wins() { .get("identity.x-tenant-username") .expect("should capture the header"); assert_eq!( - captured, "unprivileged", - "last value should win when duplicate headers are present" + captured, "admin", + "first value should win when duplicate headers are present" ); }