From 6ea086637fa3bdd9c8567f221f26cd3d78795d5c Mon Sep 17 00:00:00 2001 From: Jean-Christophe Buteau Date: Wed, 26 Aug 2026 10:52:32 -0400 Subject: [PATCH 1/4] Only enforce model rules when the request carries a model Requests without a model were denied whenever a provider or grant configured `capability.models`, because an absent model was matched against the pattern list and failed. This blocked legitimate requests to endpoints that carry no model at all, such as `GET /v1/models`. Model patterns are now evaluated only when the request actually carries a model: an absent model no longer fails a restriction, while a model that is present must still match. Provider and user-agent matching are unchanged, so a missing user agent still fails a non-empty pattern list. Co-Authored-By: Claude Opus 5 --- src/authorization.rs | 22 +++++++++++-- src/http_handlers/proxy.rs | 65 ++++++++++++++++++++++++++++++++++++++ src/matching.rs | 10 ++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/authorization.rs b/src/authorization.rs index 3bbcb7d..03e99aa 100644 --- a/src/authorization.rs +++ b/src/authorization.rs @@ -1,3 +1,4 @@ +use crate::matching::match_if_present; use crate::matching::permissive_match; use crate::request_metadata::RequestMetadata; @@ -23,7 +24,7 @@ impl Authorization { self.rules.iter().any(|rule| { permissive_match(&rule.providers, Some(provider)) - && permissive_match( + && match_if_present( &rule.model_patterns, request_metadata.inspected.model.as_deref(), ) @@ -103,7 +104,7 @@ mod tests { assert!(auth.is_allowed(&metadata("provider-a", Some("claude-sonnet-4-20250514")))); assert!(auth.is_allowed(&metadata("provider-a", Some("gpt-4o")))); assert!(!auth.is_allowed(&metadata("provider-a", Some("gpt-3.5-turbo")))); - assert!(!auth.is_allowed(&metadata("provider-a", None))); + assert!(auth.is_allowed(&metadata("provider-a", None))); // Wildcard provider with Some model assert!(auth.is_allowed(&metadata("provider-a-1", Some("claude-opus-4-20250514")))); @@ -116,6 +117,23 @@ mod tests { assert!(!auth.is_allowed(&metadata("other", Some("claude-sonnet-4-20250514")))); } + #[test] + fn test_is_allowed_without_model_skips_model_patterns() { + let auth = Authorization { + rules: vec![rule(&["provider-a"], &["claude-*"])], + }; + + // A request that carries no model is not filtered on model. + assert!(auth.is_allowed(&metadata("provider-a", None))); + + // A model that is present must still match. + assert!(auth.is_allowed(&metadata("provider-a", Some("claude-opus-5")))); + assert!(!auth.is_allowed(&metadata("provider-a", Some("gpt-4o")))); + + // Other attributes still apply when no model is present. + assert!(!auth.is_allowed(&metadata("provider-b", None))); + } + #[test] fn test_is_allowed_empty_list_allows_any() { // Empty models list means "all allowed" diff --git a/src/http_handlers/proxy.rs b/src/http_handlers/proxy.rs index ae6a272..1e7cc29 100644 --- a/src/http_handlers/proxy.rs +++ b/src/http_handlers/proxy.rs @@ -593,4 +593,69 @@ mod tests { }) ); } + #[tokio::test] + async fn proxy_allows_request_without_model_when_provider_restricts_models() { + let addr = spawn_echo_server().await; + + let mut manager = ProviderManager::new(); + manager.add( + make_provider_with_model_rules( + "myprovider", + &format!("http://{addr}"), + Compatibility { + anthropic_messages: true, + ..Default::default() + }, + vec![ModelRule { + pattern: glob::Pattern::new("claude-*").unwrap(), + rewrite: None, + }], + ) + .await, + ); + + let app = crate::app::AppBuilder::new().manager(manager).build(); + + // A request that carries no model is not filtered on model. + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/myprovider/v1/models") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // A model that is present and matches is allowed. + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/myprovider/v1/messages") + .header("content-type", "application/json") + .body(Body::from(r#"{"model":"claude-opus-5","messages":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // A model that is present and does not match is still forbidden. + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/myprovider/v1/messages") + .header("content-type", "application/json") + .body(Body::from(r#"{"model":"gpt-4o","messages":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } } diff --git a/src/matching.rs b/src/matching.rs index ede5e26..a57b2d8 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -8,6 +8,16 @@ pub fn permissive_match(patterns: &[glob::Pattern], value: Option<&str>) -> bool } } +/// Returns true if `value` matches any of `patterns`. +/// An absent value is a permissive match: only a present value can fail to +/// match. Use this for attributes that not every request carries. +pub fn match_if_present(patterns: &[glob::Pattern], value: Option<&str>) -> bool { + match value { + None => true, + Some(v) => permissive_match(patterns, Some(v)), + } +} + /// Pattern specificity as a sort key. Higher = more specific. /// Pattern with more literal (non-`*`) characters wins /// If equal, fewer `*` wildcards wins From 15da7a1cfd5a4438b7790c48a7563cd784519b1a Mon Sep 17 00:00:00 2001 From: Jean-Christophe Buteau Date: Wed, 26 Aug 2026 14:01:10 -0400 Subject: [PATCH 2/4] switch to single function and change naming --- src/authorization.rs | 9 ++++--- src/capabilities.rs | 5 ++-- src/matching.rs | 59 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/authorization.rs b/src/authorization.rs index 03e99aa..a6ce68f 100644 --- a/src/authorization.rs +++ b/src/authorization.rs @@ -1,4 +1,4 @@ -use crate::matching::match_if_present; +use crate::matching::OnAbsent; use crate::matching::permissive_match; use crate::request_metadata::RequestMetadata; @@ -23,12 +23,13 @@ impl Authorization { .map(|ua| ua.normalized.as_str()); self.rules.iter().any(|rule| { - permissive_match(&rule.providers, Some(provider)) - && match_if_present( + permissive_match(&rule.providers, Some(provider), OnAbsent::Check) + && permissive_match( &rule.model_patterns, request_metadata.inspected.model.as_deref(), + OnAbsent::Ignore, ) - && permissive_match(&rule.user_agents, user_agent) + && permissive_match(&rule.user_agents, user_agent, OnAbsent::Check) }) } diff --git a/src/capabilities.rs b/src/capabilities.rs index c9148d1..379964b 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -1,3 +1,4 @@ +use crate::matching::OnAbsent; use crate::matching::permissive_match; use crate::model_rules::ModelRule; use serde::Deserialize; @@ -43,8 +44,8 @@ impl Grant { provider_key: &str, user_agent: Option<&str>, ) -> bool { - permissive_match(&self.providers, Some(provider_key)) - && permissive_match(&self.user_agents, user_agent) + permissive_match(&self.providers, Some(provider_key), OnAbsent::Check) + && permissive_match(&self.user_agents, user_agent, OnAbsent::Check) } } diff --git a/src/matching.rs b/src/matching.rs index a57b2d8..6713c10 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -1,23 +1,30 @@ +/// What `permissive_match` does with the patterns when the value is absent. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum OnAbsent { + /// The patterns are skipped when the value is absent, so only a present + /// value can fail. Use this for attributes that not every request + /// carries, such as the model, which `/v1/models` does not have. + Ignore, + /// The patterns are applied to an absent value as an empty string, so + /// omitting the value cannot bypass the restriction. A wildcard pattern + /// still accepts it. + Check, +} + /// Returns true if `value` matches any of `patterns` /// Empty pattern list is treated as a permissive match-all. -pub fn permissive_match(patterns: &[glob::Pattern], value: Option<&str>) -> bool { +/// `absent` decides what an absent `value` means. +pub fn permissive_match(patterns: &[glob::Pattern], value: Option<&str>, absent: OnAbsent) -> bool { patterns.is_empty() || match value { - None => patterns.iter().any(|p| p.matches("")), + None => match absent { + OnAbsent::Ignore => true, + OnAbsent::Check => patterns.iter().any(|p| p.matches("")), + }, Some(v) => patterns.iter().any(|p| p.matches(v)), } } -/// Returns true if `value` matches any of `patterns`. -/// An absent value is a permissive match: only a present value can fail to -/// match. Use this for attributes that not every request carries. -pub fn match_if_present(patterns: &[glob::Pattern], value: Option<&str>) -> bool { - match value { - None => true, - Some(v) => permissive_match(patterns, Some(v)), - } -} - /// Pattern specificity as a sort key. Higher = more specific. /// Pattern with more literal (non-`*`) characters wins /// If equal, fewer `*` wildcards wins @@ -76,6 +83,34 @@ mod tests { most_specific_match(patterns, value, |p| p).map(|p| p.as_str()) } + #[test] + fn absent_value_behaviour_depends_on_absent_argument() { + let patterns = vec![pattern("claude-*")]; + + // The patterns are skipped only when asked. + assert!(permissive_match(&patterns, None, OnAbsent::Ignore)); + assert!(!permissive_match(&patterns, None, OnAbsent::Check)); + + // A present value is unaffected by the argument. + assert!(permissive_match( + &patterns, + Some("claude-opus-5"), + OnAbsent::Check + )); + assert!(!permissive_match( + &patterns, + Some("gpt-4o"), + OnAbsent::Ignore + )); + + // An empty pattern list stays permissive either way. + assert!(permissive_match(&[], None, OnAbsent::Check)); + + // `Check` matches an absent value as the empty string, so a + // wildcard still accepts it. + assert!(permissive_match(&[pattern("*")], None, OnAbsent::Check)); + } + #[test] fn wildcard_position_does_not_matter_when_matching_most_specific_pattern() { // For "claude-opus-4-8", literal counts: claude-* (7), *-opus-4-8 (9), From 50349710dc7a721b2e395a1677952e71c1d100c4 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Buteau Date: Wed, 26 Aug 2026 14:31:19 -0400 Subject: [PATCH 3/4] CR --- src/authorization.rs | 8 ++--- src/capabilities.rs | 6 ++-- src/http_handlers/proxy.rs | 65 -------------------------------------- src/matching.rs | 46 +++++++++++++-------------- 4 files changed, 29 insertions(+), 96 deletions(-) diff --git a/src/authorization.rs b/src/authorization.rs index a6ce68f..0eb3ff1 100644 --- a/src/authorization.rs +++ b/src/authorization.rs @@ -1,4 +1,4 @@ -use crate::matching::OnAbsent; +use crate::matching::OnAbsentValue; use crate::matching::permissive_match; use crate::request_metadata::RequestMetadata; @@ -23,13 +23,13 @@ impl Authorization { .map(|ua| ua.normalized.as_str()); self.rules.iter().any(|rule| { - permissive_match(&rule.providers, Some(provider), OnAbsent::Check) + permissive_match(&rule.providers, Some(provider), OnAbsentValue::Check) && permissive_match( &rule.model_patterns, request_metadata.inspected.model.as_deref(), - OnAbsent::Ignore, + OnAbsentValue::Allow, ) - && permissive_match(&rule.user_agents, user_agent, OnAbsent::Check) + && permissive_match(&rule.user_agents, user_agent, OnAbsentValue::Check) }) } diff --git a/src/capabilities.rs b/src/capabilities.rs index 379964b..fe6fd7e 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -1,4 +1,4 @@ -use crate::matching::OnAbsent; +use crate::matching::OnAbsentValue; use crate::matching::permissive_match; use crate::model_rules::ModelRule; use serde::Deserialize; @@ -44,8 +44,8 @@ impl Grant { provider_key: &str, user_agent: Option<&str>, ) -> bool { - permissive_match(&self.providers, Some(provider_key), OnAbsent::Check) - && permissive_match(&self.user_agents, user_agent, OnAbsent::Check) + permissive_match(&self.providers, Some(provider_key), OnAbsentValue::Check) + && permissive_match(&self.user_agents, user_agent, OnAbsentValue::Check) } } diff --git a/src/http_handlers/proxy.rs b/src/http_handlers/proxy.rs index 1e7cc29..ae6a272 100644 --- a/src/http_handlers/proxy.rs +++ b/src/http_handlers/proxy.rs @@ -593,69 +593,4 @@ mod tests { }) ); } - #[tokio::test] - async fn proxy_allows_request_without_model_when_provider_restricts_models() { - let addr = spawn_echo_server().await; - - let mut manager = ProviderManager::new(); - manager.add( - make_provider_with_model_rules( - "myprovider", - &format!("http://{addr}"), - Compatibility { - anthropic_messages: true, - ..Default::default() - }, - vec![ModelRule { - pattern: glob::Pattern::new("claude-*").unwrap(), - rewrite: None, - }], - ) - .await, - ); - - let app = crate::app::AppBuilder::new().manager(manager).build(); - - // A request that carries no model is not filtered on model. - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/myprovider/v1/models") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - // A model that is present and matches is allowed. - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/myprovider/v1/messages") - .header("content-type", "application/json") - .body(Body::from(r#"{"model":"claude-opus-5","messages":[]}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - // A model that is present and does not match is still forbidden. - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/myprovider/v1/messages") - .header("content-type", "application/json") - .body(Body::from(r#"{"model":"gpt-4o","messages":[]}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } } diff --git a/src/matching.rs b/src/matching.rs index 6713c10..a5dfb16 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -1,25 +1,21 @@ -/// What `permissive_match` does with the patterns when the value is absent. #[derive(Debug, Clone, Copy, PartialEq)] -pub enum OnAbsent { - /// The patterns are skipped when the value is absent, so only a present - /// value can fail. Use this for attributes that not every request - /// carries, such as the model, which `/v1/models` does not have. - Ignore, - /// The patterns are applied to an absent value as an empty string, so - /// omitting the value cannot bypass the restriction. A wildcard pattern - /// still accepts it. +pub enum OnAbsentValue { + Allow, Check, } /// Returns true if `value` matches any of `patterns` /// Empty pattern list is treated as a permissive match-all. -/// `absent` decides what an absent `value` means. -pub fn permissive_match(patterns: &[glob::Pattern], value: Option<&str>, absent: OnAbsent) -> bool { +pub fn permissive_match( + patterns: &[glob::Pattern], + value: Option<&str>, + absent: OnAbsentValue, +) -> bool { patterns.is_empty() || match value { None => match absent { - OnAbsent::Ignore => true, - OnAbsent::Check => patterns.iter().any(|p| p.matches("")), + OnAbsentValue::Allow => true, + OnAbsentValue::Check => patterns.iter().any(|p| p.matches("")), }, Some(v) => patterns.iter().any(|p| p.matches(v)), } @@ -84,31 +80,33 @@ mod tests { } #[test] - fn absent_value_behaviour_depends_on_absent_argument() { + fn absent_value_is_allowed_or_checked() { let patterns = vec![pattern("claude-*")]; - // The patterns are skipped only when asked. - assert!(permissive_match(&patterns, None, OnAbsent::Ignore)); - assert!(!permissive_match(&patterns, None, OnAbsent::Check)); + assert!(permissive_match(&patterns, None, OnAbsentValue::Allow)); + assert!(!permissive_match(&patterns, None, OnAbsentValue::Check)); // A present value is unaffected by the argument. assert!(permissive_match( &patterns, Some("claude-opus-5"), - OnAbsent::Check + OnAbsentValue::Check )); assert!(!permissive_match( &patterns, Some("gpt-4o"), - OnAbsent::Ignore + OnAbsentValue::Allow )); - // An empty pattern list stays permissive either way. - assert!(permissive_match(&[], None, OnAbsent::Check)); + assert!(permissive_match(&[], None, OnAbsentValue::Check)); - // `Check` matches an absent value as the empty string, so a - // wildcard still accepts it. - assert!(permissive_match(&[pattern("*")], None, OnAbsent::Check)); + // `Check` matches an absent value as the empty string, so a wildcard + // still accepts it. + assert!(permissive_match( + &[pattern("*")], + None, + OnAbsentValue::Check + )); } #[test] From 16be743302943a68d087d05c5d5a90593a0a703b Mon Sep 17 00:00:00 2001 From: Jean-Christophe Buteau Date: Wed, 26 Aug 2026 14:38:07 -0400 Subject: [PATCH 4/4] rename --- src/matching.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matching.rs b/src/matching.rs index a5dfb16..dc3ecde 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -9,11 +9,11 @@ pub enum OnAbsentValue { pub fn permissive_match( patterns: &[glob::Pattern], value: Option<&str>, - absent: OnAbsentValue, + on_absent_value: OnAbsentValue, ) -> bool { patterns.is_empty() || match value { - None => match absent { + None => match on_absent_value { OnAbsentValue::Allow => true, OnAbsentValue::Check => patterns.iter().any(|p| p.matches("")), },