Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/openai_files_endpoints/general_upload_validation.py: 32%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Upload validation applied to every purpose at POST /v1/files.
4batch_file_validation.py checks the JSONL shape of purpose="batch" uploads; this
5module applies the same fast-fail-before-forwarding shape (size cap, allowed and
6blocked extensions, path-traversal filenames) regardless of purpose.
7"""
9from dataclasses import dataclass
10from pathlib import Path
11from typing import BinaryIO, Final, NoReturn
13from typing_extensions import assert_never
15from litellm.proxy._types import ProxyException
16from litellm.proxy.common_utils.path_utils import safe_filename
18MB: Final = 1024 * 1024
21def coerce_optional_int_setting(raw: object) -> int | None:
22 """A general_settings value declared as an optional integer, e.g. max_file_size_mb.
24 bool is an int subclass, so an explicit isinstance(raw, bool) exclusion is needed
25 or a YAML `true`/`false` would silently pass as 1/0.
26 """
27 if raw is None: 27 ↛ 29line 27 didn't jump to line 29 because the condition on line 27 was always true
28 return None
29 if isinstance(raw, int) and not isinstance(raw, bool):
30 return raw
31 raise TypeError(f"expected an integer, got {raw!r}")
34def coerce_optional_str_list_setting(raw: object) -> tuple[str, ...] | None:
35 if raw is None:
36 return None
37 if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
38 raise TypeError(f"expected a list of strings, got {raw!r}")
39 return tuple(raw)
42@dataclass(frozen=True, slots=True)
43class UploadedFileTooLarge:
44 size_bytes: int
45 limit_mb: int
48@dataclass(frozen=True, slots=True)
49class UploadedFileExtensionNotAllowed:
50 extension: str
53@dataclass(frozen=True, slots=True)
54class UploadedFileBlockedExtension:
55 extension: str
58@dataclass(frozen=True, slots=True)
59class UploadedFileUnsafeFilename:
60 filename: str
63UploadValidationFailure = (
64 UploadedFileTooLarge | UploadedFileExtensionNotAllowed | UploadedFileBlockedExtension | UploadedFileUnsafeFilename
65)
68def _file_size_bytes(file_source: bytes | BinaryIO) -> int:
69 if isinstance(file_source, bytes):
70 return len(file_source)
71 original_position: Final = file_source.tell()
72 file_source.seek(0, 2)
73 size: Final = file_source.tell()
74 file_source.seek(original_position)
75 return size
78def check_upload_file_size(
79 file_source: bytes | BinaryIO,
80 max_file_size_mb: int | None,
81) -> UploadedFileTooLarge | None:
82 if max_file_size_mb is None or max_file_size_mb <= 0:
83 return None
84 size_bytes: Final = _file_size_bytes(file_source)
85 if size_bytes > max_file_size_mb * MB:
86 return UploadedFileTooLarge(size_bytes=size_bytes, limit_mb=max_file_size_mb)
87 return None
90def _normalized_extension(filename: str | None) -> str:
91 if not filename:
92 return ""
93 try:
94 return Path(safe_filename(filename)).suffix.lower()
95 except ValueError:
96 return ""
99def check_allowed_extension(
100 filename: str | None,
101 allowed_extensions: tuple[str, ...] | None,
102) -> UploadedFileExtensionNotAllowed | None:
103 if allowed_extensions is None:
104 return None
105 extension: Final = _normalized_extension(filename)
106 normalized_allowed: Final = frozenset(item.lower() for item in allowed_extensions)
107 if extension and extension in normalized_allowed:
108 return None
109 return UploadedFileExtensionNotAllowed(extension=extension)
112def check_blocked_extension(
113 filename: str | None,
114 blocked_extensions: tuple[str, ...] | None,
115) -> UploadedFileBlockedExtension | None:
116 if not blocked_extensions:
117 return None
118 extension: Final = _normalized_extension(filename)
119 normalized_blocked: Final = frozenset(item.lower() for item in blocked_extensions)
120 if extension and extension in normalized_blocked:
121 return UploadedFileBlockedExtension(extension=extension)
122 return None
125def check_unsafe_filename(filename: str | None) -> UploadedFileUnsafeFilename | None:
126 """Reject a filename before it can influence any storage path or backend call.
128 Only flags a genuine traversal component ("..") or a null byte, so an ordinary
129 name like "report.v2.pdf" or ".env" is never rejected.
130 """
131 if not filename: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 return None
133 if "\x00" in filename: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 return UploadedFileUnsafeFilename(filename=filename)
135 normalized: Final = filename.replace("\\", "/")
136 if any(part == ".." for part in normalized.split("/")): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 return UploadedFileUnsafeFilename(filename=filename)
138 return None
141def raise_upload_validation_failure(failure: UploadValidationFailure) -> NoReturn:
142 match failure:
143 case UploadedFileTooLarge(size_bytes=size_bytes, limit_mb=limit_mb):
144 raise ProxyException(
145 message=(
146 f"Uploaded file exceeds the configured max_file_size_mb of {limit_mb} MB "
147 f"(read stopped at {size_bytes / MB:.1f} MB). The file was not forwarded to the provider."
148 ),
149 type="invalid_request_error",
150 param="file",
151 code=413,
152 )
153 case UploadedFileExtensionNotAllowed(extension=extension):
154 raise ProxyException(
155 message=(
156 (f"File extension '{extension}'" if extension else "A file without an extension")
157 + " is not in this proxy's allowed_file_extensions setting. "
158 "The file was not forwarded to the provider."
159 ),
160 type="invalid_request_error",
161 param="file",
162 code=400,
163 )
164 case UploadedFileBlockedExtension(extension=extension):
165 raise ProxyException(
166 message=(
167 f"File extension '{extension}' is blocked by this proxy's blocked_file_extensions "
168 "setting. The file was not forwarded to the provider."
169 ),
170 type="invalid_request_error",
171 param="file",
172 code=400,
173 )
174 case UploadedFileUnsafeFilename(filename=filename):
175 raise ProxyException(
176 message=(
177 f"Filename '{filename}' is not allowed: directory traversal sequences are not "
178 "permitted in uploaded file names. The file was not forwarded to the provider."
179 ),
180 type="invalid_request_error",
181 param="file",
182 code=400,
183 )
184 case _:
185 assert_never(failure)