feat(rust): extract credential boundary validation
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled

This commit is contained in:
Yujong Lee 2026-10-24 17:46:42 +00:00 • committed by Devin AI
parent 4710c3b30e
commit 75910a62f5
9 changed files with 300 additions and 48 deletions

View file

@ -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<T>`
- 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

View file

@ -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`

View file

@ -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<InputSource, Error>
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<InputSource, Error>
}
}
fn is_request_controlled<T>(value: &Sourced<T>, optional: Option<&Sourced<String>>) -> bool {
value.source() == InputSource::Request
|| optional.is_some_and(|value| value.source() == InputSource::Request)
}
fn trusted_only(sources: &[InputSource]) -> Result<InputSource, Error> {
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<T>() -> Result<T, Error> {
Err(Error::InvalidConfiguration(
fn mixed_sources() -> Error {
Error::InvalidConfiguration(
"request-controlled Azure auth inputs cannot be combined with host credentials".into(),
))
)
}
fn build_credential(

View file

@ -0,0 +1,5 @@
# auth-types
Shared vocabulary for outbound provider authentication: `Sourced<T>` 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)

View file

@ -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<Self, UntrustedCredentialContext> {
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<Self, UntrustedCredentialContext> {
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<Item = InputSource>,
) -> 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<InputSource>,
) {
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)
);
}
}

View file

@ -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}")]

View file

@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
mod boundary;
mod credential;
mod error;
pub mod http;
@ -47,11 +48,12 @@ impl<T> Sourced<T> {
}
}
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};

View file

@ -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<InputSource>,
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<String>,
@ -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://<resource-name>.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<InputSource>,
) {
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(()));
}
}
}

View file

@ -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)
}