From 2c0233908a63525bd3cddb4bdc5735ac9e4b8d3d Mon Sep 17 00:00:00 2001 From: Brent Salisbury Date: Sun, 12 Jul 2026 12:33:34 +0000 Subject: [PATCH] feat(filter): add peer identity trust enforcement Add a generic HTTP security filter that validates downstream mTLS peer identity against configured trusted peer entries before allowing a request to continue. The filter supports matching by certificate digest, subject organization, and serial number. Requests with no verified peer identity, or with a peer identity that does not match any trusted peer entry, fail closed with 403. This keeps peer identity enforcement in Praxis as a generic proxy security primitive and avoids encoding Grid-specific semantics in core. Signed-off-by: Brent Salisbury --- .../http/security/peer_identity_trust.md | 33 ++ docs/filters/reference.md | 1 + filter/src/builtins/http/mod.rs | 2 +- filter/src/builtins/http/security/mod.rs | 6 +- .../http/security/peer_identity_trust.rs | 521 ++++++++++++++++++ filter/src/builtins/mod.rs | 6 +- filter/src/registry.rs | 5 +- tests/integration/tests/suite/tls.rs | 173 ++++++ 8 files changed, 739 insertions(+), 8 deletions(-) create mode 100644 docs/filters/http/security/peer_identity_trust.md create mode 100644 filter/src/builtins/http/security/peer_identity_trust.rs diff --git a/docs/filters/http/security/peer_identity_trust.md b/docs/filters/http/security/peer_identity_trust.md new file mode 100644 index 00000000..1421fdba --- /dev/null +++ b/docs/filters/http/security/peer_identity_trust.md @@ -0,0 +1,33 @@ + + + +# `peer_identity_trust` + +Validates that the downstream mTLS peer identity matches a configured trusted peer before allowing the request to continue. + +## Configuration Notes + +Requests without a verified peer identity are rejected with 403. Requests with a peer identity that does not match any trusted peer entry are also rejected with 403. + +Each trusted peer entry specifies one or more match fields. All configured fields on an entry must match the peer identity for that entry to accept the request. + +`cert_digest` (the SHA-256 hex digest of the peer certificate) is the strongest static match field. `organization` and `serial_number` are weaker and are primarily useful for bootstrap or controlled test configurations where cert digests are not known ahead of time. SAN/SPIFFE identity matching is planned for a follow-up. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `trusted_peers` | TrustedPeerConfig[] | yes | Trusted peer entries. | +| `trusted_peers[].cert_digest` | string | no | Lowercase hex-encoded SHA-256 certificate digest. | +| `trusted_peers[].organization` | string | no | X.509 subject organization (`O=` field). Weaker than certificate digest — useful for bootstrap and controlled test configurations where cert digests are not known ahead of time. | +| `trusted_peers[].serial_number` | string | no | Certificate serial number. | + +## Example + +```yaml +filter: peer_identity_trust +trusted_peers: + - cert_digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + - cert_digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + organization: example-org +``` diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 53e0957f..00912650 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -30,6 +30,7 @@ Built-in filters organized by protocol and category. | [`forwarded_headers`](http/security/forwarded_headers.md) | - | Injects `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers into upstream requests. | | [`guardrails`](http/security/guardrails.md) | - | Rejects requests matching string, regex, or PII rules against headers and/or body content. | | [`ip_acl`](http/security/ip_acl.md) | - | IP-based access control filter. | +| [`peer_identity_trust`](http/security/peer_identity_trust.md) | - | Validates that the downstream mTLS peer identity matches a configured trusted peer before allowing the request to continue. | | [`policy`](http/security/policy.md) | `cpex-policy-engine` | Embeds the CPEX policy engine in-process to enforce multi-source JWT identity, APL route policy, RFC 8693 token exchange, PII scanning, audit emission, and (under `body_access: read_write`) request / response body rewriting. | ## HTTP / Traffic Management diff --git a/filter/src/builtins/http/mod.rs b/filter/src/builtins/http/mod.rs index a11ae15e..9a6d49e4 100644 --- a/filter/src/builtins/http/mod.rs +++ b/filter/src/builtins/http/mod.rs @@ -16,7 +16,7 @@ pub use payload_processing::{CompressionFilter, JsonBodyFieldFilter, JsonRpcFilt pub use security::PolicyFilter; pub use security::{ ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, ForwardedHeadersFilter, - GuardrailsAction, GuardrailsFilter, IpAclFilter, PiiKind, RuleTargetKind, + GuardrailsAction, GuardrailsFilter, IpAclFilter, PeerIdentityTrustFilter, PiiKind, RuleTargetKind, }; pub use traffic_management::{ CircuitBreakerFilter, EndpointSelectorFilter, GrpcDetectionFilter, LoadBalancerFilter, RateLimitFilter, diff --git a/filter/src/builtins/http/security/mod.rs b/filter/src/builtins/http/security/mod.rs index 0f0fb0b3..3498955a 100644 --- a/filter/src/builtins/http/security/mod.rs +++ b/filter/src/builtins/http/security/mod.rs @@ -2,8 +2,8 @@ // Copyright (c) 2024 Praxis Contributors //! HTTP security filters: CORS, CSRF, IP access control, credential injection, -//! forwarded-header injection, guardrails, and the (feature-gated) CPEX policy -//! filter. +//! forwarded-header injection, guardrails, mTLS ingress trust enforcement, +//! and the (feature-gated) CPEX policy filter. mod cors; mod credential_injection; @@ -13,6 +13,7 @@ mod guardrails; mod ip_acl; pub(crate) mod origin_matcher; pub(crate) mod origin_normalize; +mod peer_identity_trust; #[cfg(feature = "cpex-policy-engine")] mod policy; @@ -22,5 +23,6 @@ pub use csrf::CsrfFilter; pub use forwarded_headers::ForwardedHeadersFilter; pub use guardrails::{ContainsValue, GuardrailsAction, GuardrailsFilter, PiiKind, RuleTargetKind}; pub use ip_acl::IpAclFilter; +pub use peer_identity_trust::PeerIdentityTrustFilter; #[cfg(feature = "cpex-policy-engine")] pub use policy::PolicyFilter; diff --git a/filter/src/builtins/http/security/peer_identity_trust.rs b/filter/src/builtins/http/security/peer_identity_trust.rs new file mode 100644 index 00000000..3cfad192 --- /dev/null +++ b/filter/src/builtins/http/security/peer_identity_trust.rs @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Peer identity trust filter: validates downstream mTLS peer identity +//! against a configured set of trusted gateway peers. + +use async_trait::async_trait; +use praxis_tls::TlsPeerIdentity; +use serde::Deserialize; + +use crate::{ + FilterAction, FilterError, Rejection, + factory::parse_filter_config, + filter::{HttpFilter, HttpFilterContext}, +}; + +/// Maximum length for string match fields. +const MAX_FIELD_LEN: usize = 256; + +/// Maximum number of trusted peer entries. +const MAX_TRUSTED_PEERS: usize = 256; + +/// Lowercase SHA-256 certificate digest length in hexadecimal characters. +const SHA256_HEX_DIGEST_LEN: usize = 64; + +// ----------------------------------------------------------------------------- +// Config +// ----------------------------------------------------------------------------- + +/// Deserialized YAML config for the peer identity trust filter. +/// +/// ```yaml +/// filter: peer_identity_trust +/// trusted_peers: +/// - cert_digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +/// - cert_digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +/// organization: example-org +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PeerIdentityTrustConfig { + /// Trusted peer entries. + trusted_peers: Vec, +} + +/// A trusted peer entry. All configured fields must match. +/// Omitted fields are not checked. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustedPeerConfig { + /// Lowercase hex-encoded SHA-256 certificate digest. + cert_digest: Option, + + /// X.509 subject organization (`O=` field). + /// + /// Weaker than certificate digest — useful for bootstrap and + /// controlled test configurations where cert digests are not + /// known ahead of time. + organization: Option, + + /// Certificate serial number. + serial_number: Option, +} + +// ----------------------------------------------------------------------------- +// PeerIdentityTrustFilter +// ----------------------------------------------------------------------------- + +/// Validates that the downstream mTLS peer identity matches a +/// configured trusted peer before allowing the request to continue. +/// +/// Requests without a verified peer identity are rejected with 403. +/// Requests with a peer identity that does not match any trusted +/// peer entry are also rejected with 403. +/// +/// Each trusted peer entry specifies one or more match fields. +/// All configured fields on an entry must match the peer identity +/// for that entry to accept the request. +/// +/// `cert_digest` (the SHA-256 hex digest of the peer certificate) +/// is the strongest static match field. `organization` and +/// `serial_number` are weaker and are primarily useful for +/// bootstrap or controlled test configurations where cert digests +/// are not known ahead of time. SAN/SPIFFE identity matching is +/// planned for a follow-up. +/// +/// # YAML configuration +/// +/// ```yaml +/// filter: peer_identity_trust +/// trusted_peers: +/// - cert_digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +/// - cert_digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +/// organization: example-org +/// ``` +pub struct PeerIdentityTrustFilter { + /// Validated trusted peer entries. + trusted_peers: Vec, +} + +/// Validated trusted peer entry for runtime matching. +struct TrustedPeer { + /// Lowercase hex SHA-256 certificate digest. + cert_digest: Option, + + /// X.509 subject organization. + organization: Option, + + /// Certificate serial number. + serial_number: Option, +} + +impl PeerIdentityTrustFilter { + /// Create a peer identity trust filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if the trusted peer list is empty, + /// exceeds the maximum, or a peer entry has no match fields. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: PeerIdentityTrustConfig = parse_filter_config("peer_identity_trust", config)?; + + if cfg.trusted_peers.is_empty() { + return Err("peer_identity_trust: trusted_peers must not be empty".into()); + } + if cfg.trusted_peers.len() > MAX_TRUSTED_PEERS { + return Err(format!("peer_identity_trust: trusted_peers exceeds maximum of {MAX_TRUSTED_PEERS}").into()); + } + + let peers = validate_peers(cfg.trusted_peers)?; + Ok(Box::new(Self { trusted_peers: peers })) + } +} + +#[async_trait] +impl HttpFilter for PeerIdentityTrustFilter { + fn name(&self) -> &'static str { + "peer_identity_trust" + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let Some(identity) = &ctx.peer_identity else { + tracing::warn!("peer identity trust: no peer identity; rejecting"); + return Ok(FilterAction::Reject(Rejection::status(403))); + }; + + if is_trusted(identity, &self.trusted_peers) { + tracing::debug!( + peer_digest = %identity.hex_digest(), + peer_org = identity.organization.as_deref().unwrap_or(""), + "peer identity trust: peer accepted" + ); + Ok(FilterAction::Continue) + } else { + tracing::warn!( + peer_digest = %identity.hex_digest(), + "peer identity trust: peer not trusted; rejecting" + ); + Ok(FilterAction::Reject(Rejection::status(403))) + } + } +} + +// ----------------------------------------------------------------------------- +// Private Helpers +// ----------------------------------------------------------------------------- + +/// Validate and build the trusted peer list from config. +fn validate_peers(raw: Vec) -> Result, FilterError> { + let mut peers = Vec::with_capacity(raw.len()); + for (i, p) in raw.into_iter().enumerate() { + validate_cert_digest_field(&format!("trusted_peers[{i}].cert_digest"), &p.cert_digest)?; + validate_optional_field(&format!("trusted_peers[{i}].organization"), &p.organization)?; + validate_optional_field(&format!("trusted_peers[{i}].serial_number"), &p.serial_number)?; + + if p.cert_digest.is_none() && p.organization.is_none() && p.serial_number.is_none() { + return Err( + format!("peer_identity_trust: trusted_peers[{i}] must specify at least one match field").into(), + ); + } + + peers.push(TrustedPeer { + cert_digest: p.cert_digest, + organization: p.organization, + serial_number: p.serial_number, + }); + } + Ok(peers) +} + +/// Reject missing, malformed, or mixed-case SHA-256 digest strings. +fn validate_cert_digest_field(name: &str, value: &Option) -> Result<(), FilterError> { + let Some(v) = value else { + return Ok(()); + }; + + if v.len() != SHA256_HEX_DIGEST_LEN || !v.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err(format!("peer_identity_trust: {name} must be exactly 64 lowercase hex characters").into()); + } + + Ok(()) +} + +/// Reject empty or oversized optional string fields. +fn validate_optional_field(name: &str, value: &Option) -> Result<(), FilterError> { + if let Some(v) = value + && (v.trim().is_empty() || v.len() > MAX_FIELD_LEN) + { + return Err(format!("peer_identity_trust: {name} must be 1-{MAX_FIELD_LEN} non-blank characters").into()); + } + Ok(()) +} + +/// Check whether the peer identity matches any trusted peer entry. +fn is_trusted(identity: &TlsPeerIdentity, peers: &[TrustedPeer]) -> bool { + peers.iter().any(|peer| matches_peer(identity, peer)) +} + +/// Check whether a single peer entry matches the identity. +/// All configured fields must match. +fn matches_peer(identity: &TlsPeerIdentity, peer: &TrustedPeer) -> bool { + if let Some(digest) = &peer.cert_digest + && identity.hex_digest() != *digest + { + return false; + } + if let Some(org) = &peer.organization + && identity.organization.as_deref() != Some(org.as_str()) + { + return false; + } + if let Some(serial) = &peer.serial_number + && identity.serial_number.as_deref() != Some(serial.as_str()) + { + return false; + } + true +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] +mod tests { + use http::Method; + + use super::*; + + // ---- Config validation ---- + + #[test] + fn empty_trusted_peers_rejected() { + let err = parse("trusted_peers: []").err().expect("should fail"); + assert!(err.to_string().contains("must not be empty"), "{err}"); + } + + #[test] + fn peer_with_no_fields_rejected() { + let err = parse("trusted_peers:\n - {}").err().expect("should fail"); + assert!(err.to_string().contains("at least one match field"), "{err}"); + } + + #[test] + fn empty_organization_rejected() { + let err = parse("trusted_peers:\n - organization: ''") + .err() + .expect("should fail"); + assert!(err.to_string().contains("organization must be"), "{err}"); + } + + #[test] + fn blank_organization_rejected() { + let err = parse("trusted_peers:\n - organization: ' '") + .err() + .expect("should fail"); + assert!(err.to_string().contains("organization must be"), "{err}"); + } + + #[test] + fn empty_cert_digest_rejected() { + let err = parse("trusted_peers:\n - cert_digest: ''").err().expect("should fail"); + assert!(err.to_string().contains("cert_digest must be"), "{err}"); + } + + #[test] + fn empty_serial_number_rejected() { + let err = parse("trusted_peers:\n - serial_number: ''") + .err() + .expect("should fail"); + assert!(err.to_string().contains("serial_number must be"), "{err}"); + } + + #[test] + fn too_many_trusted_peers_rejected() { + use std::fmt::Write as _; + + let mut yaml = String::from("trusted_peers:\n"); + for i in 0..=MAX_TRUSTED_PEERS { + writeln!(yaml, " - organization: org-{i}").expect("String write is infallible"); + } + + let err = parse(&yaml).err().expect("should fail"); + assert!(err.to_string().contains("exceeds maximum"), "{err}"); + } + + #[test] + fn oversized_organization_rejected() { + let org = "a".repeat(MAX_FIELD_LEN + 1); + let err = parse(&format!("trusted_peers:\n - organization: {org}")) + .err() + .expect("should fail"); + assert!(err.to_string().contains("organization must be"), "{err}"); + } + + #[test] + fn short_cert_digest_rejected() { + let err = parse("trusted_peers:\n - cert_digest: abcd") + .err() + .expect("should fail"); + assert!(err.to_string().contains("exactly 64 lowercase hex"), "{err}"); + } + + #[test] + fn uppercase_cert_digest_rejected() { + let err = parse(&format!("trusted_peers:\n - cert_digest: {}", "AB".repeat(32))) + .err() + .expect("should fail"); + assert!(err.to_string().contains("exactly 64 lowercase hex"), "{err}"); + } + + #[test] + fn non_hex_cert_digest_rejected() { + let err = parse(&format!("trusted_peers:\n - cert_digest: {}", "gg".repeat(32))) + .err() + .expect("should fail"); + assert!(err.to_string().contains("exactly 64 lowercase hex"), "{err}"); + } + + #[test] + fn valid_digest_only_config() { + assert!(parse(&format!("trusted_peers:\n - cert_digest: {}", digest_hex(0xAB))).is_ok()); + } + + #[test] + fn valid_org_only_config() { + assert!(parse("trusted_peers:\n - organization: test-org").is_ok()); + } + + #[test] + fn valid_combined_config() { + assert!( + parse(&format!( + "trusted_peers:\n - cert_digest: {}\n organization: test-org\n serial_number: '42'", + digest_hex(0xAB) + )) + .is_ok() + ); + } + + // ---- Trust decisions ---- + + #[tokio::test] + async fn no_peer_identity_rejects() { + let f = make_org_filter("test-org"); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "missing peer identity should reject with 403" + ); + } + + #[tokio::test] + async fn trusted_digest_accepts() { + let f = make_digest_filter(&digest_hex(0xAB)); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![0xAB; 32], "any-org", "1")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "trusted digest should accept"); + } + + #[tokio::test] + async fn wrong_digest_rejects() { + let f = make_digest_filter(&digest_hex(0xAB)); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![0xCD; 32], "any-org", "1")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "wrong digest should reject" + ); + } + + #[tokio::test] + async fn trusted_organization_accepts() { + let f = make_org_filter("test-org"); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![1], "test-org", "1")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "trusted org should accept"); + } + + #[tokio::test] + async fn wrong_organization_rejects() { + let f = make_org_filter("test-org"); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![1], "wrong-org", "1")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "wrong org should reject" + ); + } + + #[tokio::test] + async fn trusted_serial_accepts() { + let f = parse("trusted_peers:\n - serial_number: '42'").unwrap(); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![1], "any", "42")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "trusted serial should accept"); + } + + #[tokio::test] + async fn wrong_serial_rejects() { + let f = parse("trusted_peers:\n - serial_number: '42'").unwrap(); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![1], "any", "99")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "wrong serial should reject" + ); + } + + #[tokio::test] + async fn combined_fields_must_all_match() { + let f = parse(&format!( + "trusted_peers:\n - cert_digest: '{}'\n organization: good-org", + digest_hex(0x01) + )) + .unwrap(); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(make_identity(vec![0x01; 32], "wrong-org", "1")); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "digest match + org mismatch should still reject" + ); + } + + #[tokio::test] + async fn identity_without_organization_rejects_org_check() { + let f = make_org_filter("test-org"); + let req = crate::test_utils::make_request(Method::GET, "/"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.peer_identity = Some(TlsPeerIdentity { + cert_digest: vec![1], + organization: None, + serial_number: None, + }); + + let action = f.on_request(&mut ctx).await.unwrap(); + assert!( + matches!(action, FilterAction::Reject(r) if r.status == 403), + "identity missing org should reject when org is required" + ); + } + + // ---- Test Utilities ---- + + fn parse(yaml: &str) -> Result, FilterError> { + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + PeerIdentityTrustFilter::from_config(&val) + } + + fn make_org_filter(org: &str) -> Box { + parse(&format!("trusted_peers:\n - organization: {org}")).unwrap() + } + + fn make_digest_filter(hex: &str) -> Box { + parse(&format!("trusted_peers:\n - cert_digest: {hex}")).unwrap() + } + + fn digest_hex(byte: u8) -> String { + format!("{byte:02x}").repeat(32) + } + + fn make_identity(digest: Vec, org: &str, serial: &str) -> TlsPeerIdentity { + TlsPeerIdentity { + cert_digest: digest, + organization: Some(org.to_owned()), + serial_number: Some(serial.to_owned()), + } + } +} diff --git a/filter/src/builtins/mod.rs b/filter/src/builtins/mod.rs index 1527fce6..8482ac18 100644 --- a/filter/src/builtins/mod.rs +++ b/filter/src/builtins/mod.rs @@ -12,8 +12,8 @@ pub use http::{ AccessLogFilter, CircuitBreakerFilter, CompressionFilter, ContainsValue, CorsFilter, CredentialInjectionFilter, CsrfFilter, DisallowedOriginMode, EndpointSelectorFilter, ForwardedHeadersFilter, GrpcDetectionFilter, GuardrailsAction, GuardrailsFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, JsonRpcFilter, - LoadBalancerFilter, PathRewriteFilter, PiiKind, RateLimitFilter, RateLimitMode, RedirectFilter, RedirectStatus, - RequestIdFilter, RouterFilter, RuleTargetKind, StaticResponseFilter, TimeoutFilter, UrlRewriteFilter, - has_dot_dot_traversal, normalize_rewritten_path, + LoadBalancerFilter, PathRewriteFilter, PeerIdentityTrustFilter, PiiKind, RateLimitFilter, RateLimitMode, + RedirectFilter, RedirectStatus, RequestIdFilter, RouterFilter, RuleTargetKind, StaticResponseFilter, TimeoutFilter, + UrlRewriteFilter, has_dot_dot_traversal, normalize_rewritten_path, }; pub use tcp::{SniRouterFilter, TcpAccessLogFilter, TcpLoadBalancerFilter}; diff --git a/filter/src/registry.rs b/filter/src/registry.rs index 59e11894..114142e9 100644 --- a/filter/src/registry.rs +++ b/filter/src/registry.rs @@ -111,8 +111,8 @@ fn register_http_builtins(factories: &mut HashMap) { use crate::builtins::{ AccessLogFilter, CircuitBreakerFilter, CompressionFilter, CorsFilter, CredentialInjectionFilter, CsrfFilter, ForwardedHeadersFilter, GrpcDetectionFilter, HeaderFilter, IpAclFilter, JsonBodyFieldFilter, JsonRpcFilter, - PathRewriteFilter, RateLimitFilter, RedirectFilter, RequestIdFilter, StaticResponseFilter, TimeoutFilter, - UrlRewriteFilter, + PathRewriteFilter, PeerIdentityTrustFilter, RateLimitFilter, RedirectFilter, RequestIdFilter, + StaticResponseFilter, TimeoutFilter, UrlRewriteFilter, }; register_http(factories, "access_log", AccessLogFilter::from_config); @@ -148,6 +148,7 @@ fn register_http_builtins(factories: &mut HashMap) { register_http(factories, "url_rewrite", UrlRewriteFilter::from_config); register_http(factories, "json_body_field", JsonBodyFieldFilter::from_config); register_http(factories, "json_rpc", JsonRpcFilter::from_config); + register_http(factories, "peer_identity_trust", PeerIdentityTrustFilter::from_config); } /// Register a single HTTP filter factory by name. diff --git a/tests/integration/tests/suite/tls.rs b/tests/integration/tests/suite/tls.rs index a3f795cf..822fb97c 100644 --- a/tests/integration/tests/suite/tls.rs +++ b/tests/integration/tests/suite/tls.rs @@ -2651,3 +2651,176 @@ impl rustls::client::danger::ServerCertVerifier for NoHostnameVerifier { self.0.supported_verify_schemes() } } +// peer_identity_trust integration tests +// ----------------------------------------------------------------------------- + +/// Verify that a client cert with a matching org is allowed through. +#[test] +fn peer_identity_trust_allows_matching_org() { + let certs = TestCertificates::generate(); + let client_cert = certs.generate_client_cert_with_org("trusted-org"); + let client_config = certs.client_config_with_cert(&client_cert); + + let backend_port_guard = start_backend_with_shutdown("trust-allowed"); + let backend_port = backend_port_guard.port(); + let proxy_port = free_port(); + + let yaml = format!( + r#" +listeners: + - name: secure + address: "127.0.0.1:{proxy_port}" + filter_chains: + - main + tls: + certificates: + - cert_path: "{cert}" + key_path: "{key}" + client_ca: + ca_path: "{ca}" + client_cert_mode: require +filter_chains: + - name: main + filters: + - filter: peer_identity_trust + trusted_peers: + - organization: trusted-org + - filter: router + routes: + - path_prefix: "/" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:{backend_port}" +"#, + cert = certs.cert_path.display(), + key = certs.key_path.display(), + ca = certs.ca_cert_path.display(), + ); + + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_tls_proxy_no_wait(&config); + wait_for_https(proxy.addr(), &client_config); + + let (status, body) = https_get(proxy.addr(), "/", &client_config); + assert_eq!( + status, 200, + "peer_identity_trust must allow cert with matching org (got {status}, body: {body})" + ); + assert_eq!(body, "trust-allowed", "backend should be reached"); +} + +/// Verify that a cert signed by the same CA but with a non-matching org is rejected. +#[test] +fn peer_identity_trust_rejects_wrong_org() { + let certs = TestCertificates::generate(); + let wrong_cert = certs.generate_client_cert_with_org("untrusted-org"); + let wrong_config = certs.client_config_with_cert(&wrong_cert); + + let backend_port_guard = start_backend_with_shutdown("trust-wrong-org"); + let backend_port = backend_port_guard.port(); + let proxy_port = free_port(); + + let yaml = format!( + r#" +listeners: + - name: secure + address: "127.0.0.1:{proxy_port}" + filter_chains: + - main + tls: + certificates: + - cert_path: "{cert}" + key_path: "{key}" + client_ca: + ca_path: "{ca}" + client_cert_mode: require +filter_chains: + - name: main + filters: + - filter: peer_identity_trust + trusted_peers: + - organization: trusted-org + - filter: router + routes: + - path_prefix: "/" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:{backend_port}" +"#, + cert = certs.cert_path.display(), + key = certs.key_path.display(), + ca = certs.ca_cert_path.display(), + ); + + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_tls_proxy_no_wait(&config); + wait_for_https(proxy.addr(), &wrong_config); + + let (status, _body) = https_get(proxy.addr(), "/", &wrong_config); + assert_eq!( + status, 403, + "peer_identity_trust must reject cert with non-matching org (got {status})" + ); +} + +/// Verify that a connection without a client cert (request mode) is rejected by the filter. +#[test] +fn peer_identity_trust_rejects_no_peer_identity() { + let certs = TestCertificates::generate(); + let no_cert_config = certs.client_config(); + + let backend_port_guard = start_backend_with_shutdown("trust-no-cert"); + let backend_port = backend_port_guard.port(); + let proxy_port = free_port(); + + let yaml = format!( + r#" +listeners: + - name: secure + address: "127.0.0.1:{proxy_port}" + filter_chains: + - main + tls: + certificates: + - cert_path: "{cert}" + key_path: "{key}" + client_ca: + ca_path: "{ca}" + client_cert_mode: request +filter_chains: + - name: main + filters: + - filter: peer_identity_trust + trusted_peers: + - organization: trusted-org + - filter: router + routes: + - path_prefix: "/" + cluster: backend + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:{backend_port}" +"#, + cert = certs.cert_path.display(), + key = certs.key_path.display(), + ca = certs.ca_cert_path.display(), + ); + + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_tls_proxy_no_wait(&config); + wait_for_https(proxy.addr(), &no_cert_config); + + let (status, _body) = https_get(proxy.addr(), "/", &no_cert_config); + assert_eq!( + status, 403, + "peer_identity_trust must reject connections with no peer identity (got {status})" + ); +}