Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/filters/identity_header_guard.md
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"
```
6 changes: 6 additions & 0 deletions docs/filters/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
38 changes: 38 additions & 0 deletions examples/configs/identity-header-guard.yaml
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"
49 changes: 49 additions & 0 deletions filters/src/identity_guard/config.rs
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(())
}
128 changes: 128 additions & 0 deletions filters/src/identity_guard/mod.rs
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

Copy link
Copy Markdown
Collaborator

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 the request_headers_to_remove push 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()), runs on_request, and asserts the header is in request_headers_to_remove but absent from filter_metadata. For a security guard filter, this divergent code path deserves explicit coverage.

Copy link
Copy Markdown
Author

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_captured test that inserts raw bytes as a header value, verifies it's stripped but not captured to metadata.

// 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;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] set_metadata silently overwrites when the same header name appears multiple times in a request. HTTP allows duplicate headers, and HeaderMap iteration yields every (name, value) pair, so if a client sends:

x-tenant-username: admin
x-tenant-username: unprivileged

The security property (stripping) is preserved because request_headers_to_remove collects every match. But set_metadata keeps only the last value, and iteration order over a HeaderMap with duplicate keys is insertion-order but not contractually guaranteed to stay that way across http crate versions.

For a filter that feeds metering and audit, the captured metadata should be deterministic. Consider either:

  1. First-wins (defensive) -- skip if the key already exists in metadata:
    let namespaced = format!("{}.{}", self.namespace, name_lower);
    if !ctx.filter_metadata.contains_key(&namespaced) {
        ctx.set_metadata(namespaced, val.to_owned());
    }
  2. Join with comma -- combine values per HTTP semantics:
    ctx.filter_metadata
        .entry(namespaced)
        .and_modify(|existing| { existing.push_str(", "); existing.push_str(val); })
        .or_insert_with(|| val.to_owned());

First-wins is safer for identity headers (trust the first value set by the auth layer, ignore client-appended duplicates).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added duplicate_headers_last_value_wins test that appends two values for the same header and asserts the last one is captured. This is the correct behavior — HeaderMap iteration yields all pairs in insertion order, and set_metadata overwrites, so the last value wins deterministically. Documented in the test assertion message.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: the last-wins vs first-wins point above — the new duplicate_headers_last_value_wins test documents the current behavior, but the bot's original recommendation was specifically to change it to first-wins for security reasons (trust the auth layer's first value, not a client-appended duplicate). Adding a test for the existing behavior doesn't close that gap — worth either implementing the contains_key guard from the original suggestion, or a short note here on why last-wins is intentionally safe in this deployment model.

Separate note on the #581 sync above: I pulled that PR's diff — as it stands today external_metering only strips headers, it doesn't yet read filter_metadata/identity.* (that's presumably PR 2 of the series, "Identity capture, balance check, admission control", not yet open). So the two filters are safely redundant today, but the trusted-path benefit you describe isn't in the code yet — might be worth a one-line note in #581 once PR 2 lands, so the dependency is explicit rather than implied.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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: identity_header_guard runs after api_key_auth or jwt_auth in the pipeline. The auth filter writes verified identity to filter_metadata first — that's the trusted source. The guard only captures headers for deployments where identity comes from an upstream proxy (Authorino/Kuadrant) that sets a single canonical header, not from client-controlled duplicates.

That said, a contains_key guard (first-wins) is strictly safer — I'll add it. It costs nothing and removes the ambiguity.

#581 dependency: Agreed — the trusted-path benefit is architectural intent, not yet wired in external_metering. I'll add a note on #581 once the metadata-reading path lands. Good catch keeping the dependency explicit.

ctx.request_headers_to_remove.push(name.clone());
}

if captured > 0 {
trace!(captured, prefix = %self.prefix, "identity headers captured and stripped");
}

Ok(FilterAction::Continue)
}
}
Loading
Loading