diff --git a/litellm-rust/.agents/skills/rust-credential-boundary/SKILL.md b/litellm-rust/.agents/skills/rust-credential-boundary/SKILL.md new file mode 100644 index 00000000000..66e50da34c8 --- /dev/null +++ b/litellm-rust/.agents/skills/rust-credential-boundary/SKILL.md @@ -0,0 +1,83 @@ +--- +name: rust-credential-boundary +description: Add or change outbound credential and resource provenance checks in litellm-rust, or the host code that labels where request inputs came from, so a request can never steer a host-owned credential to a caller-chosen endpoint, scope, project or space +--- + +# Rust credential boundary + +A request must never pair a resource it controls, such as `api_base`, `scope`, `project_id` or `space_id`, with a credential the host owns. Three layers decide this. Python's edge filter predates Rust, the host labels inputs on the way into Rust, and the shared boundary checks those labels + +- Edge filter, in the Python proxy, name based. See Existing Python +- Labeling, in the host, records who supplied each input as `InputSource` on `Sourced` +- Enforcement, in `CredentialBoundary` (`crates/auth-types/src/boundary.rs`) + - `new` accepts a nonempty credential bundle that is all request-owned or all host-owned, and `host_only` also rejects request-owned bundles + - `validate_resources` rejects any request-owned resource once the credentials are host-owned + - It trusts labels completely. A wrong label passes, and a missing label means `Deployment` + +## Existing Python + +The proxy filters caller-controlled endpoints and identity selectors before provider dispatch. Its field-name checks do not establish provenance or authorize a Rust host to relabel an input + +- `is_request_body_safe` in `litellm/proxy/auth/auth_utils.py` + - Rejects names in `_BANNED_REQUEST_BODY_PARAMS` unless an admin opts in. Rejection becomes a 400 at the proxy auth boundary + - The list includes `api_base`, `base_url`, `user_config`, AWS identity selectors, `vertex_credentials`, `vertex_ai_credentials`, `azure_ad_token`, endpoint-targeting fields and callback credentials and hosts + - Checks the top-level body and one level of `_NESTED_CONFIG_KEYS` (`extra_body`, `litellm_embedding_config`) and `_NESTED_METADATA_KEYS` (`metadata`, `litellm_metadata`), including bracket-form metadata + - Separately walks fallback targets, including `router_settings_override`, with bounded recursion in `iter_request_fallback_targets`. URL-valued fallback model names are rejected + - `general_settings.allow_client_side_credentials: true` bypasses the banned-name loop proxy-wide. A model group's `configurable_clientside_auth_params` permits individual names, or `api_base` values matching a regex + - These opt-ins do not bypass every check. Top-level `model_list`, server-owned federation fields and federated credential references have separate restrictions, with route-specific management exceptions + - The per-model opt-in uses `get_model_group_info(model)` with the requested model name and a provider-prefix fallback. It does not authorize against the resolved serving deployment, so it cannot justify relabeling a resource as deployment-owned + - A caller-supplied `api_key` does not bypass this filter. The old `check_complete_credentials` bypass is no longer used here + - Azure `scope` and `authority`, and Vertex `project` and `location`, are not banned by those names. The filter is not a general resource-ownership check +- `litellm/router_utils/clientside_credential_handler.py` + - Copies caller `api_key`, `api_base` and `base_url` onto the deployment's `litellm_params` + - On a caller base override, rejects an effective `api_key` that is an `oidc/` secret reference. A caller-supplied key has already replaced the deployment key at this point + - Clears `_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE`, including organization, `extra_body`, region, token, Vertex and AWS fields, then restores fields the caller supplied. It retains the effective `api_key` + - Preserves per-user Copilot credential selection when locked and disables Anthropic workload identity federation on redirected calls +- What reaches Rust + - `request_input_sources` in `crates/python-bridge/src/marshal.rs` reads `proxy_server_request.body_fields` (or `body`) and `credential_fields`. Among the names the route asks it to inspect, it labels matching names `Request` + - It never reads the admin opt-ins or recursively discovers nested aliases. The host must supply all relevant names and propagate their labels + - An allowed caller `api_base` therefore remains `Request`. When a route enforces `CredentialBoundary`, host credentials still reject it. Fully request-owned credentials can pass `new`, but not a provider's `host_only` or stricter check + - This is the intended rule for pure Rust routes. For a route with a Python fallback, record any parity divergence instead of hiding it by relabeling +- What Python does not give you + - The name-based filter and config merge do not create Rust ownership labels + - The banned-name list covers some non-endpoint selectors, but is not exhaustive. Providers must validate protected resources such as scope, authority, project and space + - Python-only routes still need their edge filter, stripping and provider-specific checks until they move to Rust + +## Providers + +- Pass every credential component to `CredentialBoundary::new` + - Use `host_only` for ambient identities, client assertions and host file references +- Pass every protected resource to `validate_resources` + - The endpoint, issuer or authority, audience or scope, and project or space + - Every alias and nested spelling, before precedence resolution drops one. An equal request value is still request-owned +- Run the check before credential acquisition or any provider IO +- Map `UntrustedCredentialContext` onto the provider's existing public error, so accepted combinations and messages stay the same +- References are `crates/auth-azure/src/native.rs` and `crates/llms/src/azure_ai/common_utils.rs` + - Vertex OCR deliberately keeps its stricter standalone `validate_destination` in `crates/llms/src/vertex_ai/ocr/common_utils.rs`. It rejects any supplied `Request` `api_base`, even with request-owned credentials. This PR does not loosen that policy + +## Hosts + +- Every field the caller sent is `Request`, including nested fields, aliases and connection headers + - HTTP input never carries a label or an authorization flag + - Deployment config and in-process SDK callers are `Deployment`, and environment lookups are `Environment` +- Relabeling a caller field as `Deployment` because an admin opt-in allows it is the risky step + - See Existing Python for why the edge opt-in is not serving-deployment authorization. The boundary cannot detect a wrong label +- What to do depends on the route + - Pure Rust with no Python fallback, such as tokenize + - Never relabel. Caller fields are always `Request` + - Proxy calls still pass through the edge filter. SDK calls must not rely on it + - A caller who wants to override a resource sends a full credential set + - Rust with a Python fallback that must keep parity + - If Python honors an opt-in, enforcing request provenance in Rust can diverge + - Any compatibility authorization must use the resolved serving deployment's config. Otherwise preserve `Request` and record the divergence + - Python only + - Keep the edge filter, field stripping and provider-specific checks until the route moves to Rust + +## Tests + +- Cover the `InputSource` cross product for each credential component and resource with `#[rstest]` and `#[values(...)]`, asserting the rule instead of a hand-listed table +- Add one named case per alias or nested spelling +- Keep the provider's existing error message assertions +- Follow-up for the tokenize host layer, which is not added here + - With the first host that labels tokenize inputs, add a test that a caller field stays `Request` even when the deployment lists it in `configurable_clientside_auth_params` + - Add the test's file path here when it lands. This PR does not change host labeling or claim that test exists diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md index e62a9463cb5..4a82ac77f8a 100644 --- a/litellm-rust/AGENTS.md +++ b/litellm-rust/AGENTS.md @@ -4,6 +4,8 @@ For diagnostic tracing changes, follow [.agents/skills/rust-tracing/SKILL.md](.a For string-valued enums and their Serde conversions, follow [.agents/skills/rust-string-enums/SKILL.md](.agents/skills/rust-string-enums/SKILL.md) +For outbound credential and resource provenance checks, in a provider or a host, follow [.agents/skills/rust-credential-boundary/SKILL.md](.agents/skills/rust-credential-boundary/SKILL.md) + For fieldless enums, derive `strum::VariantArray` and use `VARIANTS` instead of a hand-listed `ALL` array. Derive Strum string conversions instead of hand-written variant-to-string matches Use the derived conversions directly (`<&'static str>::from(x)` / `.into()`, `str::parse`) with no `as_str`/`parse` wrapper that only delegates, and spell each variant with explicit `#[strum(serialize = "...")]` instead of `serialize_all` diff --git a/litellm-rust/crates/auth-azure/src/native.rs b/litellm-rust/crates/auth-azure/src/native.rs index 64752162384..0d7932a620f 100644 --- a/litellm-rust/crates/auth-azure/src/native.rs +++ b/litellm-rust/crates/auth-azure/src/native.rs @@ -13,7 +13,9 @@ use azure_identity::{ use sha2::{Digest, Sha256}; use litellm_auth_types::Error; -use litellm_auth_types::{InputSource, ResolvedCredential, SecretValue, Sourced}; +use litellm_auth_types::{ + CredentialBoundary, InputSource, ResolvedCredential, SecretValue, Sourced, +}; use super::credential_provider_cache::{ AzureCredentialProviderCache, AzureCredentialProviderCacheKey, @@ -286,22 +288,14 @@ fn validate_sources(request: &NativeAzureRequest) -> Result client_id.source(), client_secret.source(), ]; - let request_identity = identity_sources.contains(&InputSource::Request); - if request_identity - && !identity_sources - .iter() - .all(|source| *source == InputSource::Request) - { - return mixed_sources(); - } - if !request_identity && is_request_controlled(scope, authority.as_ref()) { - return mixed_sources(); - } - Ok(if request_identity { - InputSource::Request - } else { - trusted_source(&identity_sources) - }) + let boundary = + CredentialBoundary::new(&identity_sources).map_err(|_| mixed_sources())?; + boundary + .validate_resources( + std::iter::once(scope.source()).chain(authority.as_ref().map(Sourced::source)), + ) + .map_err(|_| mixed_sources())?; + Ok(boundary.source()) } NativeAzureRequest::ClientAssertion { tenant_id, @@ -355,30 +349,16 @@ fn validate_sources(request: &NativeAzureRequest) -> Result } } -fn is_request_controlled(value: &Sourced, optional: Option<&Sourced>) -> bool { - value.source() == InputSource::Request - || optional.is_some_and(|value| value.source() == InputSource::Request) -} - fn trusted_only(sources: &[InputSource]) -> Result { - if sources.contains(&InputSource::Request) { - return mixed_sources(); - } - Ok(trusted_source(sources)) + CredentialBoundary::host_only(sources) + .map(|boundary| boundary.source()) + .map_err(|_| mixed_sources()) } -fn trusted_source(sources: &[InputSource]) -> InputSource { - if sources.contains(&InputSource::Deployment) { - InputSource::Deployment - } else { - InputSource::Environment - } -} - -fn mixed_sources() -> Result { - Err(Error::InvalidConfiguration( +fn mixed_sources() -> Error { + Error::InvalidConfiguration( "request-controlled Azure auth inputs cannot be combined with host credentials".into(), - )) + ) } fn build_credential( diff --git a/litellm-rust/crates/auth-types/AGENTS.md b/litellm-rust/crates/auth-types/AGENTS.md new file mode 100644 index 00000000000..427153de8f0 --- /dev/null +++ b/litellm-rust/crates/auth-types/AGENTS.md @@ -0,0 +1,5 @@ +# auth-types + +Shared vocabulary for outbound provider authentication: `Sourced` and `InputSource`, credential plans and resolvers, token providers, `SecretValue`, and the `CredentialBoundary` in `src/boundary.rs` + +`CredentialBoundary` stops a request-controlled resource from being paired with a host-owned credential. Keep this crate provider-neutral, so field names, aliases and messages belong to the calling provider. Before adding a credential or resource check to a provider, or labeling inputs in a host, follow [.agents/skills/rust-credential-boundary/SKILL.md](../../.agents/skills/rust-credential-boundary/SKILL.md) diff --git a/litellm-rust/crates/auth-types/src/boundary.rs b/litellm-rust/crates/auth-types/src/boundary.rs new file mode 100644 index 00000000000..353ddab8733 --- /dev/null +++ b/litellm-rust/crates/auth-types/src/boundary.rs @@ -0,0 +1,129 @@ +use crate::{InputSource, UntrustedCredentialContext}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CredentialBoundary { + source: InputSource, +} + +impl CredentialBoundary { + pub fn new(sources: &[InputSource]) -> Result { + if sources.is_empty() { + return Err(UntrustedCredentialContext); + } + let request_owned = sources.contains(&InputSource::Request); + if request_owned && sources.iter().any(|source| *source != InputSource::Request) { + return Err(UntrustedCredentialContext); + } + let source = if request_owned { + InputSource::Request + } else if sources.contains(&InputSource::Deployment) { + InputSource::Deployment + } else { + InputSource::Environment + }; + Ok(Self { source }) + } + + pub fn host_only(sources: &[InputSource]) -> Result { + let boundary = Self::new(sources)?; + if boundary.source == InputSource::Request { + return Err(UntrustedCredentialContext); + } + Ok(boundary) + } + + pub fn source(&self) -> InputSource { + self.source + } + + pub fn validate_resources( + &self, + sources: impl IntoIterator, + ) -> Result<(), UntrustedCredentialContext> { + if self.source != InputSource::Request + && sources + .into_iter() + .any(|source| source == InputSource::Request) + { + return Err(UntrustedCredentialContext); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn credential_and_resource_source_combinations( + #[values( + InputSource::Request, + InputSource::Deployment, + InputSource::Environment + )] + identity: InputSource, + #[values( + InputSource::Request, + InputSource::Deployment, + InputSource::Environment + )] + secret: InputSource, + #[values( + InputSource::Request, + InputSource::Deployment, + InputSource::Environment + )] + resource: InputSource, + ) { + let result = CredentialBoundary::new(&[identity, secret]) + .and_then(|boundary| boundary.validate_resources([resource])); + let request_owned = identity == InputSource::Request && secret == InputSource::Request; + let host_owned = identity != InputSource::Request && secret != InputSource::Request; + assert_eq!( + result.is_ok(), + request_owned || (host_owned && resource != InputSource::Request) + ); + } + + #[rstest] + #[case::environment(&[InputSource::Environment], Some(InputSource::Environment))] + #[case::deployment(&[InputSource::Deployment, InputSource::Environment], Some(InputSource::Deployment))] + #[case::request(&[InputSource::Request], None)] + #[case::mixed(&[InputSource::Request, InputSource::Deployment], None)] + fn host_credentials_require_host_sources( + #[case] sources: &[InputSource], + #[case] expected: Option, + ) { + assert_eq!( + CredentialBoundary::host_only(sources) + .ok() + .map(|boundary| boundary.source()), + expected + ); + } + + #[rstest] + fn an_empty_credential_bundle_cannot_authorize_resources() { + assert_eq!( + CredentialBoundary::new(&[]), + Err(UntrustedCredentialContext) + ); + } + + #[rstest] + fn every_supplied_resource_is_checked_even_when_a_trusted_value_exists() { + let trusted = crate::Sourced::new("configured", InputSource::Deployment); + let supplied = crate::Sourced::new("override", InputSource::Request); + let boundary = CredentialBoundary::new(&[InputSource::Environment]).unwrap(); + assert_eq!( + boundary.validate_resources([trusted.source(), supplied.source()]), + Err(UntrustedCredentialContext) + ); + assert_eq!( + boundary.validate_resources([supplied.source()]), + Err(UntrustedCredentialContext) + ); + } +} diff --git a/litellm-rust/crates/auth-types/src/error.rs b/litellm-rust/crates/auth-types/src/error.rs index ff17dcf3810..e6ce63c3eaf 100644 --- a/litellm-rust/crates/auth-types/src/error.rs +++ b/litellm-rust/crates/auth-types/src/error.rs @@ -1,5 +1,9 @@ use thiserror::Error as ThisError; +#[derive(Clone, Copy, Debug, ThisError, PartialEq, Eq)] +#[error("request-controlled inputs cannot be combined with host credentials")] +pub struct UntrustedCredentialContext; + #[derive(Clone, Debug, ThisError, PartialEq, Eq)] pub enum Error { #[error("invalid authentication configuration: {0}")] diff --git a/litellm-rust/crates/auth-types/src/lib.rs b/litellm-rust/crates/auth-types/src/lib.rs index c875bda7979..bd4f70b01c4 100644 --- a/litellm-rust/crates/auth-types/src/lib.rs +++ b/litellm-rust/crates/auth-types/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +mod boundary; mod credential; mod error; pub mod http; @@ -47,11 +48,12 @@ impl Sourced { } } +pub use boundary::CredentialBoundary; pub use credential::{ CredentialFileRef, CredentialLookup, CredentialLookupFuture, CredentialPlan, CredentialPlanResolution, CredentialRef, CredentialResolver, CredentialResolverHandle, }; -pub use error::{Error, ErrorDetail, ErrorSource}; +pub use error::{Error, ErrorDetail, ErrorSource, UntrustedCredentialContext}; pub use http::CredentialPlacement; pub use params::{AwsParams, ParamSpec, VertexParams}; pub use policy::{CredentialPlanKind, CredentialRule, ExistingHeaderBehavior, ProviderAuthPolicy}; diff --git a/litellm-rust/crates/llms/src/azure_ai/common_utils.rs b/litellm-rust/crates/llms/src/azure_ai/common_utils.rs index 3aba089f58e..a37d1634842 100644 --- a/litellm-rust/crates/llms/src/azure_ai/common_utils.rs +++ b/litellm-rust/crates/llms/src/azure_ai/common_utils.rs @@ -1,9 +1,23 @@ use crate::Error; +use litellm_auth::{CredentialBoundary, InputSource}; use litellm_core_utils::settings::resolve_non_empty; pub const AZURE_API_KEY_ENV: &str = "AZURE_API_KEY"; pub const AZURE_API_BASE_ENV: &str = "AZURE_API_BASE"; +pub(crate) fn validate_destination( + endpoint_source: Option, + credential_source: InputSource, +) -> Result<(), litellm_auth::Error> { + CredentialBoundary::new(&[credential_source]) + .and_then(|boundary| boundary.validate_resources(endpoint_source)) + .map_err(|_| { + litellm_auth::Error::InvalidConfiguration( + "host credentials cannot be sent to a request-controlled Azure endpoint".into(), + ) + }) +} + pub fn resolve_azure_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, @@ -23,3 +37,38 @@ pub fn resolve_azure_api_base( resolve_non_empty(api_base, env_lookup, &[AZURE_API_BASE_ENV]) .ok_or_else(|| Error::from(litellm_auth::Error::MissingApiBase { provider: "Azure", guidance: "Set `api_base` or the AZURE_API_BASE environment variable. Expected format: https://.services.ai.azure.com/anthropic" })) } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn destination_source_combinations( + #[values( + InputSource::Request, + InputSource::Deployment, + InputSource::Environment + )] + credential: InputSource, + #[values( + None, + Some(InputSource::Request), + Some(InputSource::Deployment), + Some(InputSource::Environment) + )] + endpoint: Option, + ) { + let result = validate_destination(endpoint, credential); + if endpoint == Some(InputSource::Request) && credential != InputSource::Request { + assert_eq!( + result, + Err(litellm_auth::Error::InvalidConfiguration( + "host credentials cannot be sent to a request-controlled Azure endpoint".into(), + )) + ); + } else { + assert_eq!(result, Ok(())); + } + } +} diff --git a/litellm-rust/crates/llms/src/azure_ai/ocr/common_utils.rs b/litellm-rust/crates/llms/src/azure_ai/ocr/common_utils.rs index 8cbf2455e01..0f285faab28 100644 --- a/litellm-rust/crates/llms/src/azure_ai/ocr/common_utils.rs +++ b/litellm-rust/crates/llms/src/azure_ai/ocr/common_utils.rs @@ -43,14 +43,12 @@ pub(super) fn validate_destination( connection: &OcrConnection, credential_source: InputSource, ) -> Result<(), Error> { - if connection.api_base.is_some() - && connection.api_base_source == InputSource::Request - && credential_source != InputSource::Request - { - return Err(litellm_auth::Error::InvalidConfiguration( - "host credentials cannot be sent to a request-controlled Azure endpoint".into(), - ) - .into()); - } - Ok(()) + crate::azure_ai::common_utils::validate_destination( + connection + .api_base + .as_ref() + .map(|_| connection.api_base_source), + credential_source, + ) + .map_err(Error::from) }