mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(auth): log routine 401 rejections as one warning without a traceback (#48613)
A missing or malformed Authorization header, a blocked key, a non-admin key on an admin-only or master-key-only route and a SCIM-deactivated user were raised as bare exceptions, so the auth failure handler could not see the 401 and logged each one at ERROR with a full traceback, which flooded the log under scanner traffic They now raise a typed 401 carrying the quiet-log marker that the non-sk bearer rejection already used, and the unknown sk- key rejection joins the same path, so each lands as one WARNING line on stdout with no traceback. The client response is unchanged
This commit is contained in:
parent
618d5aec71
commit
34134d7ae5
7 changed files with 88 additions and 33 deletions
|
|
@ -24,6 +24,7 @@ from litellm.proxy.auth.auth_utils import ( # noqa: F401 # legacy module expor
|
|||
_get_request_ip_address, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
get_request_ip_address,
|
||||
is_invalid_virtual_key_error,
|
||||
is_unauthorized_error,
|
||||
mark_invalid_virtual_key_error,
|
||||
normalize_request_route,
|
||||
)
|
||||
|
|
@ -200,11 +201,11 @@ class UserAPIKeyAuthExceptionHandler:
|
|||
user_agent: Final = _get_user_agent(request)
|
||||
|
||||
# Log authentication failures before identity seeding and callbacks, so the log
|
||||
# survives a raising callback pipeline. Classify and route malformed virtual-key
|
||||
# rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR).
|
||||
# survives a raising callback pipeline. Route 401 rejections to WARNING on stdout
|
||||
# (suppressible via LITELLM_LOG=ERROR); anything else is unexpected and logs at ERROR.
|
||||
log_extra: Final = {"requester_ip": requester_ip}
|
||||
is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e)
|
||||
is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks
|
||||
is_quiet_log: Final = is_unauthorized_error(e) and not litellm.log_client_error_tracebacks
|
||||
logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger
|
||||
logger.log(
|
||||
logging.WARNING if is_quiet_log else logging.ERROR,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,16 @@ from litellm.types.utils import CustomPricingLiteLLMParams, oauth_token_exchange
|
|||
from litellm.types.videos.main import DecodedVideoId
|
||||
|
||||
|
||||
def is_unauthorized_error(exception: BaseException | None) -> bool:
|
||||
"""True when the proxy itself rejected the caller's credentials with a 401."""
|
||||
if not isinstance(exception, (HTTPException, ProxyException)):
|
||||
return False
|
||||
|
||||
code: Final[object] = getattr(exception, "code", None)
|
||||
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
|
||||
return str(status_code) == str(status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
|
||||
"""True when an authentication error rejects a malformed virtual key.
|
||||
|
||||
|
|
@ -57,15 +67,7 @@ def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
|
|||
match would let a request body demote an authorization failure to the
|
||||
quiet log path.
|
||||
"""
|
||||
if not isinstance(exception, (HTTPException, ProxyException)):
|
||||
return False
|
||||
|
||||
code: Final[object] = getattr(exception, "code", None)
|
||||
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
|
||||
if str(status_code) != str(status.HTTP_401_UNAUTHORIZED):
|
||||
return False
|
||||
|
||||
return getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True
|
||||
return is_unauthorized_error(exception) and getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True
|
||||
|
||||
|
||||
def log_model_access_denial(exc: BaseException) -> None:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from litellm.proxy._types import (
|
|||
LiteLLM_UserTable,
|
||||
LiteLLMRoutes,
|
||||
LitellmUserRoles,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
|
||||
|
|
@ -284,7 +286,7 @@ class RouteChecks:
|
|||
route (str): The route being accessed
|
||||
|
||||
Raises:
|
||||
Exception: With user role and masked user_id information
|
||||
ProxyException: 401 with user role and masked user_id information
|
||||
"""
|
||||
user_role = "unknown"
|
||||
user_id = "unknown"
|
||||
|
|
@ -293,8 +295,14 @@ class RouteChecks:
|
|||
user_id = user_obj.user_id or "unknown"
|
||||
|
||||
masked_user_id: Final = RouteChecks._mask_user_id(user_id)
|
||||
raise Exception(
|
||||
f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
|
||||
raise ProxyException(
|
||||
message=(
|
||||
"Authentication Error, Only proxy admin can be used to generate, delete, update info for new "
|
||||
f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
|
||||
),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -2513,10 +2513,20 @@ async def user_api_key_auth_builder(
|
|||
parent_otel_span=parent_otel_span,
|
||||
)
|
||||
elif api_key is None: # only require api key if master key is set
|
||||
raise Exception("No api key passed in.")
|
||||
raise ProxyException(
|
||||
message="Authentication Error, No api key passed in.",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
elif api_key == "":
|
||||
# missing 'Bearer ' prefix
|
||||
raise Exception("Malformed API Key passed in. Ensure Key has `Bearer ` prefix.")
|
||||
raise ProxyException(
|
||||
message="Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if route == "/user/auth":
|
||||
if general_settings.get("allow_user_auth", False) is True:
|
||||
|
|
@ -2752,7 +2762,12 @@ async def user_api_key_auth_builder(
|
|||
## IF it's not a master key
|
||||
## Route should not be in master_key_only_routes
|
||||
if route in LiteLLMRoutes.master_key_only_routes.value:
|
||||
raise Exception(f"Tried to access route={route}, which is only for MASTER KEY")
|
||||
raise ProxyException(
|
||||
message=f"Authentication Error, Tried to access route={route}, which is only for MASTER KEY",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
## Check DB
|
||||
|
||||
|
|
@ -2880,7 +2895,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
|
|||
|
||||
## base case ## key is disabled
|
||||
if valid_token.blocked is True:
|
||||
raise Exception("Key is blocked. Update via `/key/unblock` if you're an admin.")
|
||||
raise ProxyException(
|
||||
message="Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
await enforce_key_and_fallback_model_access(
|
||||
valid_token=valid_token,
|
||||
request_data=request_data,
|
||||
|
|
@ -2923,8 +2943,14 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
|
|||
and isinstance(user_obj.metadata, dict)
|
||||
and user_obj.metadata.get("scim_active") is False
|
||||
):
|
||||
raise Exception(
|
||||
f"User={valid_token.user_id} has been deactivated via SCIM. Keys owned by this user cannot be used."
|
||||
raise ProxyException(
|
||||
message=(
|
||||
f"Authentication Error, User={valid_token.user_id} has been deactivated via SCIM. "
|
||||
"Keys owned by this user cannot be used."
|
||||
),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Check 2a. Check if model has zero cost - if so, skip all budget checks
|
||||
|
|
@ -3241,7 +3267,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
|
|||
|
||||
if valid_token is None:
|
||||
# No token was found when looking up in the DB
|
||||
raise Exception("Invalid proxy server token passed")
|
||||
raise ProxyException(
|
||||
message="Authentication Error, Invalid proxy server token passed",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if valid_token_dict is not None:
|
||||
virtual_key_auth_obj: Final = await _return_user_api_key_auth_obj(
|
||||
user_obj=user_obj,
|
||||
|
|
|
|||
|
|
@ -668,7 +668,7 @@ async def test_expired_key_error_log_names_the_key_owner(
|
|||
):
|
||||
verbose_proxy_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException):
|
||||
with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException):
|
||||
await handler.handle_authentication_error(
|
||||
expired_key_error,
|
||||
MagicMock(),
|
||||
|
|
@ -714,8 +714,8 @@ async def test_expired_key_error_log_names_the_key_owner(
|
|||
async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
|
||||
looked_up, logged_fragment, absent_fragment, caplog
|
||||
):
|
||||
"""A wrong key gets a generic 401, while the one auth failure log line keeps the
|
||||
operator wording and the hash that was looked up, next to the requester IP."""
|
||||
"""A wrong key gets a generic 401, while the one auth failure log line, a WARNING with no
|
||||
traceback, keeps the operator wording and the hash that was looked up, next to the requester IP."""
|
||||
from litellm.constants import INVALID_API_KEY_ERROR_MESSAGE
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError
|
||||
|
||||
|
|
@ -733,7 +733,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
|
|||
):
|
||||
verbose_proxy_logger.propagate = True
|
||||
try:
|
||||
with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info:
|
||||
with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info:
|
||||
await UserAPIKeyAuthExceptionHandler().handle_authentication_error(
|
||||
KeyNotFoundError(looked_up),
|
||||
MagicMock(),
|
||||
|
|
@ -748,6 +748,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
|
|||
assert exc_info.value.message == INVALID_API_KEY_ERROR_MESSAGE
|
||||
records = [r for r in caplog.records if "user_api_key_auth(): Exception occured" in r.getMessage()]
|
||||
assert len(records) == 1, [r.getMessage() for r in caplog.records]
|
||||
assert records[0].levelname == "WARNING" and records[0].exc_info is None
|
||||
logged = records[0].getMessage()
|
||||
assert "Requester IP Address:" in logged and logged_fragment in logged, logged
|
||||
if absent_fragment is not None:
|
||||
|
|
@ -1148,10 +1149,16 @@ def _marked_malformed_key_error() -> HTTPException:
|
|||
message="Authentication Error", type=ProxyErrorTypes.auth_error, param=None, code=401
|
||||
),
|
||||
False,
|
||||
"ERROR",
|
||||
id="expected_401_no_traceback",
|
||||
"WARNING",
|
||||
id="proxy_exception_401_warning_no_traceback",
|
||||
),
|
||||
pytest.param(ValueError("unexpected internal error"), True, "ERROR", id="unexpected_error_keeps_traceback"),
|
||||
pytest.param(
|
||||
Exception("No api key passed in."),
|
||||
True,
|
||||
"ERROR",
|
||||
id="bare_exception_coerced_to_401_still_keeps_traceback",
|
||||
),
|
||||
pytest.param(
|
||||
_marked_malformed_key_error(),
|
||||
False,
|
||||
|
|
@ -1161,8 +1168,14 @@ def _marked_malformed_key_error() -> HTTPException:
|
|||
pytest.param(
|
||||
HTTPException(status_code=401, detail="LiteLLM Virtual Key expected. Received=test"),
|
||||
False,
|
||||
"WARNING",
|
||||
id="http_exception_401_warning_no_traceback",
|
||||
),
|
||||
pytest.param(
|
||||
ProxyException(message="Forbidden", type=ProxyErrorTypes.auth_error, param=None, code=403),
|
||||
False,
|
||||
"ERROR",
|
||||
id="phrase_without_marker_stays_loud",
|
||||
id="403_stays_error_without_traceback",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
@ -1189,7 +1202,7 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
|
|||
try:
|
||||
try:
|
||||
raise auth_error
|
||||
except (ProxyException, ValueError, HTTPException) as caught:
|
||||
except Exception as caught:
|
||||
with caplog.at_level(expect_level, logger="LiteLLM Proxy"), pytest.raises((ProxyException, HTTPException)):
|
||||
await handler.handle_authentication_error(
|
||||
caught,
|
||||
|
|
|
|||
|
|
@ -3742,7 +3742,7 @@ def test_team_spend_by_user_reachable_by_non_admin(user_role):
|
|||
return "allowed"
|
||||
|
||||
assert outcome("/team/spend/by_user") == "allowed"
|
||||
assert outcome("/team/spend/by_key").startswith("denied: Only proxy admin")
|
||||
assert outcome("/team/spend/by_key").startswith("denied: Authentication Error, Only proxy admin")
|
||||
|
||||
|
||||
def test_user_daily_activity_aggregated_not_covered_by_prefix_match():
|
||||
|
|
|
|||
|
|
@ -2031,9 +2031,9 @@ async def test_user_api_key_auth_websocket_rejection_adds_no_traceback_for_other
|
|||
assert rejection.value.status_code == 403
|
||||
assert "expired key" in str(rejection.value.detail).lower()
|
||||
assert sent == [{"type": "websocket.close", "code": status.WS_1008_POLICY_VIOLATION, "reason": ""}]
|
||||
proxy_records: Final = [record for record in caplog.records if record.name == verbose_proxy_logger.name]
|
||||
proxy_records: Final = [record for record in caplog.records if record.name.startswith(verbose_proxy_logger.name)]
|
||||
assert [record.getMessage() for record in proxy_records if record.exc_info is not None] == []
|
||||
assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.ERROR]
|
||||
assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.WARNING]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue