mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(mcp): wrap upstream credential secrets in SecretStr (Greptile P1)
Credential fields were plain str, so they would render verbatim in repr(), model_dump(), and any structured log that serialises the config. Wrap them in pydantic SecretStr so they show as '**********' everywhere while the resolver unwraps with get_secret_value(): client_secret on the authorization_code and client_credentials configs, secret_access_key and session_token on aws_sigv4, the shared api_key value, and the inbound passthrough token on Subject. The api_key and passthrough arms unwrap at the point of use; a regression test asserts the value never appears in model_dump_json.
This commit is contained in:
parent
6bae09bdeb
commit
f73e9d61bc
3 changed files with 22 additions and 9 deletions
|
|
@ -80,7 +80,9 @@ class UpstreamCredentialProvider:
|
|||
case SharedKey() as source:
|
||||
# The shared key is read straight from ServerSpec.config, not the per-user
|
||||
# store; it is the same credential for every caller.
|
||||
return Ok(StaticHeaderAuth(config.header_for(source.value)))
|
||||
return Ok(
|
||||
StaticHeaderAuth(config.header_for(source.value.get_secret_value()))
|
||||
)
|
||||
case PerUserEnvVar():
|
||||
value = self._per_user_value(subject, server)
|
||||
if value is None:
|
||||
|
|
@ -119,7 +121,9 @@ class UpstreamCredentialProvider:
|
|||
return Error(
|
||||
CredError.of_unauthorized("passthrough: no inbound token to forward")
|
||||
)
|
||||
return Ok(StaticHeaderAuth(f"Bearer {subject.inbound_token}"))
|
||||
return Ok(
|
||||
StaticHeaderAuth(f"Bearer {subject.inbound_token.get_secret_value()}")
|
||||
)
|
||||
|
||||
# --- arms awaiting their collaborators (typed stubs, fail closed) ----------------------
|
||||
def _authorization_code(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from enum import Enum
|
|||
from typing import Annotated, Literal
|
||||
|
||||
from expression import case, tag, tagged_union
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from ..result import Error, Ok, Result
|
||||
|
|
@ -133,7 +133,7 @@ class AuthorizationCodeConfig(BaseModel):
|
|||
model_config = ConfigDict(frozen=True)
|
||||
kind: Literal[AuthSpecKind.authorization_code] = AuthSpecKind.authorization_code
|
||||
client_id: str
|
||||
client_secret: str
|
||||
client_secret: SecretStr
|
||||
authorization_url: str
|
||||
token_url: str
|
||||
scopes: tuple[str, ...] = ()
|
||||
|
|
@ -145,7 +145,7 @@ class ClientCredentialsConfig(BaseModel):
|
|||
model_config = ConfigDict(frozen=True)
|
||||
kind: Literal[AuthSpecKind.client_credentials] = AuthSpecKind.client_credentials
|
||||
client_id: str
|
||||
client_secret: str
|
||||
client_secret: SecretStr
|
||||
token_url: str
|
||||
scopes: tuple[str, ...] = ()
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ class SharedKey(BaseModel):
|
|||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
source: Literal["shared"] = "shared"
|
||||
value: str
|
||||
value: SecretStr
|
||||
|
||||
|
||||
class PerUserEnvVar(BaseModel):
|
||||
|
|
@ -240,8 +240,8 @@ class AwsSigV4Config(BaseModel):
|
|||
region: str
|
||||
service: str = "bedrock-agentcore"
|
||||
access_key_id: str | None = None
|
||||
secret_access_key: str | None = None
|
||||
session_token: str | None = None
|
||||
secret_access_key: SecretStr | None = None
|
||||
session_token: SecretStr | None = None
|
||||
role_arn: str | None = None
|
||||
session_name: str | None = None
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ class Subject(BaseModel):
|
|||
tenant_id: str
|
||||
subject_id: str
|
||||
# Opaque, already-validated inbound identity. Only `token_exchange` / `passthrough` read it.
|
||||
inbound_token: str | None = None
|
||||
inbound_token: SecretStr | None = None
|
||||
|
||||
|
||||
class ServerSpec(BaseModel):
|
||||
|
|
|
|||
|
|
@ -88,6 +88,15 @@ def test_api_key_emits_the_right_scheme(scheme: str, expected: str):
|
|||
assert _applied_headers(result.ok)["Authorization"] == expected
|
||||
|
||||
|
||||
def test_secret_fields_are_masked_in_serialization():
|
||||
# SecretStr keeps the value out of model_dump / repr / logs but usable in the resolver.
|
||||
config = ApiKeyConfig(key_source=SharedKey(value="SUPER-SECRET"))
|
||||
dumped = config.model_dump_json()
|
||||
assert "SUPER-SECRET" not in dumped
|
||||
assert "**********" in dumped
|
||||
assert config.key_source.value.get_secret_value() == "SUPER-SECRET"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [Byok(), PerUserEnvVar()])
|
||||
def test_api_key_per_user_pulls_the_subject_credential(source: object):
|
||||
store = InMemoryCredentialStore(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue