From 5870052a2d49497ceeeedbdbf7248c4f0c98dc8e Mon Sep 17 00:00:00 2001 From: naaa760 Date: Fri, 20 Mar 2026 15:15:08 +0530 Subject: [PATCH] sanitize S3 log key filename to prevent 403 for s3:// batch file IDs --- litellm/integrations/s3.py | 26 ++++++++++++++++- .../integrations/test_s3_object_key.py | 29 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/integrations/test_s3_object_key.py diff --git a/litellm/integrations/s3.py b/litellm/integrations/s3.py index 2e70b1d6519..545e2aba6f8 100644 --- a/litellm/integrations/s3.py +++ b/litellm/integrations/s3.py @@ -185,12 +185,36 @@ def get_s3_object_key( start_time: datetime, s3_file_name: str, ) -> str: + # `s3_file_name` sometimes comes from an upstream "id" that can be an S3 URI + # (e.g. `s3://bucket/some/key.jsonl`). If we include `/` directly in the + # object key filename component, it creates extra path segments and can + # break S3 bucket/prefix IAM policies. + raw_file_name = "" if s3_file_name is None else str(s3_file_name) + import hashlib + import re + + # Replace any characters outside the safe filename set. + # This also converts `/` and `:` into `_`, preventing accidental directory + # traversal / extra IAM prefix segments. + safe_file_name = re.sub(r"[^A-Za-z0-9._-]+", "_", raw_file_name) + safe_file_name = safe_file_name.strip("_") + + if not safe_file_name: + # Fall back to a deterministic value to avoid empty keys. + safe_file_name = "unknown" + + # Keep keys reasonably small even if `id` is a long URI. + max_len = 180 + if len(safe_file_name) > max_len: + suffix_hash = hashlib.sha256(raw_file_name.encode("utf-8")).hexdigest()[:12] + safe_file_name = f"{safe_file_name[: max_len - 13]}_{suffix_hash}" + s3_object_key = ( (s3_path.rstrip("/") + "/" if s3_path else "") + prefix + start_time.strftime("%Y-%m-%d") + "/" - + s3_file_name + + safe_file_name ) # we need the s3 key to include the time, so we log cache hits too s3_object_key += ".json" return s3_object_key diff --git a/tests/test_litellm/integrations/test_s3_object_key.py b/tests/test_litellm/integrations/test_s3_object_key.py new file mode 100644 index 00000000000..bea2efbe227 --- /dev/null +++ b/tests/test_litellm/integrations/test_s3_object_key.py @@ -0,0 +1,29 @@ +from datetime import datetime, timezone + +from litellm.integrations.s3 import get_s3_object_key + + +def test_get_s3_object_key_sanitizes_embedded_s3_uri_in_filename(): + start_time = datetime(2026, 3, 9, 17, 40, 11, 901585, tzinfo=timezone.utc) + s3_path = "LiteLLMAPPLogs" + prefix = "" + s3_file_name = ( + "time-17-40-11-901585_s3://bucket-int/litellm-bedrock-files-us.anthropic." + "claude-sonnet-4-5-20250929-v1-0-29ea93-452e-8a2f.jsonl.json" + ) + + s3_object_key = get_s3_object_key( + s3_path=s3_path, + prefix=prefix, + start_time=start_time, + s3_file_name=s3_file_name, + ) + + assert s3_object_key.startswith("LiteLLMAPPLogs/2026-03-09/") + assert s3_object_key.endswith(".json") + + # The only path separators allowed are the ones we generate for prefix/date. + file_component = s3_object_key[len("LiteLLMAPPLogs/2026-03-09/") :] + assert "/" not in file_component + assert "s3://" not in s3_object_key +