mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
feat(rust): extract credential boundary validation
This commit is contained in:
parent
4710c3b30e
commit
75910a62f5
9 changed files with 300 additions and 48 deletions
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
5
litellm-rust/crates/auth-types/AGENTS.md
Normal file
5
litellm-rust/crates/auth-types/AGENTS.md
Normal 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)
|
||||
129
litellm-rust/crates/auth-types/src/boundary.rs
Normal file
129
litellm-rust/crates/auth-types/src/boundary.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue