-
Notifications
You must be signed in to change notification settings - Fork 40
feat(filter): add identity header guard filter #709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <!-- Generated by: cargo xtask generate-filter-docs --> | ||
| <!-- Do not edit manually --> | ||
|
|
||
| # `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" | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Box<dyn HttpFilter>, 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<FilterAction, FilterError> { | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] The security property (stripping) is preserved because For a filter that feeds metering and audit, the captured metadata should be deterministic. Consider either:
First-wins is safer for identity headers (trust the first value set by the auth layer, ignore client-appended duplicates).
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re: the last-wins vs first-wins point above — the new Separate note on the #581 sync above: I pulled that PR's diff — as it stands today
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point on both counts. Last-wins vs first-wins: You're right that documenting the existing behavior isn't the same as closing the security gap. The reason last-wins is safe here: That said, a #581 dependency: Agreed — the trusted-path benefit is architectural intent, not yet wired in |
||
| ctx.request_headers_to_remove.push(name.clone()); | ||
| } | ||
|
|
||
| if captured > 0 { | ||
| trace!(captured, prefix = %self.prefix, "identity headers captured and stripped"); | ||
| } | ||
|
|
||
| Ok(FilterAction::Continue) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] The
if let Ok(val)guard correctly skips capturing non-UTF-8 values while therequest_headers_to_removepush on line 114 still strips them — exactly the right security behavior. However, there is no unit test covering this edge case.Add a test that inserts a matching-prefix header with a non-UTF-8 value (
HeaderValue::from_bytes(&[0x80]).unwrap()), runson_request, and asserts the header is inrequest_headers_to_removebut absent fromfilter_metadata. For a security guard filter, this divergent code path deserves explicit coverage.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. Added
non_utf8_header_stripped_but_not_capturedtest that inserts raw bytes as a header value, verifies it's stripped but not captured to metadata.