mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(proxy): add general_settings.allowed_file_extensions for /v1/files
Opt-in allowlist for upload filename extensions, checked before the existing blocked_file_extensions blocklist and mapped through the same upload validation failure path. None keeps today's behaviour, [] rejects every upload, matching is case-insensitive on both sides, and a filename with no extension is rejected when the allowlist is set. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
cab1e113f7
commit
82ef6ea6ab
8 changed files with 254 additions and 17 deletions
|
|
@ -2635,9 +2635,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
None,
|
||||
description="max file size in MB for /v1/files uploads, for any purpose, if a file is larger than this size it will be rejected before being forwarded to the provider",
|
||||
)
|
||||
allowed_file_extensions: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description="the only file extensions (e.g. ['.jsonl', '.pdf', '.txt']) accepted on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Files with any other extension, or none, are rejected. An empty list rejects every upload. Unset means no allowlist is applied",
|
||||
)
|
||||
blocked_file_extensions: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description="file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename",
|
||||
description="file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Deprecated in favour of allowed_file_extensions; still enforced, after the allowlist, when set",
|
||||
)
|
||||
max_response_size_mb: int | None = Field(
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
|
|||
)
|
||||
from litellm.proxy.openai_files_endpoints.general_upload_validation import (
|
||||
MB,
|
||||
check_allowed_extension,
|
||||
check_blocked_extension,
|
||||
check_unsafe_filename,
|
||||
check_upload_file_size,
|
||||
|
|
@ -473,6 +474,11 @@ async def create_file(
|
|||
if general_size_failure is not None:
|
||||
raise_upload_validation_failure(general_size_failure)
|
||||
|
||||
allowed_extensions: Final = coerce_optional_str_list_setting(general_settings.get("allowed_file_extensions"))
|
||||
allowed_extension_failure: Final = check_allowed_extension(file.filename, allowed_extensions)
|
||||
if allowed_extension_failure is not None:
|
||||
raise_upload_validation_failure(allowed_extension_failure)
|
||||
|
||||
blocked_extensions: Final = coerce_optional_str_list_setting(general_settings.get("blocked_file_extensions"))
|
||||
blocked_extension_failure: Final = check_blocked_extension(file.filename, blocked_extensions)
|
||||
if blocked_extension_failure is not None:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
Upload validation applied to every purpose at POST /v1/files.
|
||||
|
||||
batch_file_validation.py checks the JSONL shape of purpose="batch" uploads; this
|
||||
module applies the same fast-fail-before-forwarding shape (size cap, blocked
|
||||
extensions, path-traversal filenames) regardless of purpose.
|
||||
module applies the same fast-fail-before-forwarding shape (size cap, allowed and
|
||||
blocked extensions, path-traversal filenames) regardless of purpose.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -31,10 +31,13 @@ def coerce_optional_int_setting(raw: object) -> int | None:
|
|||
raise TypeError(f"expected an integer, got {raw!r}")
|
||||
|
||||
|
||||
def coerce_optional_str_list_setting(raw: object) -> tuple[str, ...]:
|
||||
"""A general_settings value declared as an optional list of strings, e.g. blocked_file_extensions."""
|
||||
def coerce_optional_str_list_setting(raw: object) -> tuple[str, ...] | None:
|
||||
"""A general_settings value declared as an optional list of strings, e.g. allowed_file_extensions.
|
||||
|
||||
None (unset) and [] (set to nothing) are different answers for an allowlist, so both survive.
|
||||
"""
|
||||
if raw is None:
|
||||
return ()
|
||||
return None
|
||||
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
|
||||
raise TypeError(f"expected a list of strings, got {raw!r}")
|
||||
return tuple(raw)
|
||||
|
|
@ -46,6 +49,11 @@ class UploadedFileTooLarge:
|
|||
limit_mb: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadedFileExtensionNotAllowed:
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadedFileBlockedExtension:
|
||||
extension: str
|
||||
|
|
@ -56,7 +64,9 @@ class UploadedFileUnsafeFilename:
|
|||
filename: str
|
||||
|
||||
|
||||
UploadValidationFailure = UploadedFileTooLarge | UploadedFileBlockedExtension | UploadedFileUnsafeFilename
|
||||
UploadValidationFailure = (
|
||||
UploadedFileTooLarge | UploadedFileExtensionNotAllowed | UploadedFileBlockedExtension | UploadedFileUnsafeFilename
|
||||
)
|
||||
|
||||
|
||||
def _file_size_bytes(file_source: bytes | BinaryIO) -> int:
|
||||
|
|
@ -81,19 +91,36 @@ def check_upload_file_size(
|
|||
return None
|
||||
|
||||
|
||||
def _normalized_extension(filename: str | None) -> str:
|
||||
if not filename:
|
||||
return ""
|
||||
try:
|
||||
return Path(safe_filename(filename)).suffix.lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def check_allowed_extension(
|
||||
filename: str | None,
|
||||
allowed_extensions: tuple[str, ...] | None,
|
||||
) -> UploadedFileExtensionNotAllowed | None:
|
||||
"""None means the allowlist is not configured; an empty tuple means nothing is allowed."""
|
||||
if allowed_extensions is None:
|
||||
return None
|
||||
extension: Final = _normalized_extension(filename)
|
||||
normalized_allowed: Final = frozenset(item.lower() for item in allowed_extensions)
|
||||
if extension and extension in normalized_allowed:
|
||||
return None
|
||||
return UploadedFileExtensionNotAllowed(extension=extension)
|
||||
|
||||
|
||||
def check_blocked_extension(
|
||||
filename: str | None,
|
||||
blocked_extensions: tuple[str, ...],
|
||||
blocked_extensions: tuple[str, ...] | None,
|
||||
) -> UploadedFileBlockedExtension | None:
|
||||
if not blocked_extensions or not filename:
|
||||
if not blocked_extensions:
|
||||
return None
|
||||
try:
|
||||
extension: Final = Path(safe_filename(filename)).suffix.lower()
|
||||
except ValueError:
|
||||
return None
|
||||
# The uploaded name's extension is normalized above; blocked_extensions comes
|
||||
# straight from config.yaml or the DB and is normalized here too, so a
|
||||
# differently-cased entry (".EXE") still catches a lowercase upload.
|
||||
extension: Final = _normalized_extension(filename)
|
||||
normalized_blocked: Final = frozenset(item.lower() for item in blocked_extensions)
|
||||
if extension and extension in normalized_blocked:
|
||||
return UploadedFileBlockedExtension(extension=extension)
|
||||
|
|
@ -128,6 +155,17 @@ def raise_upload_validation_failure(failure: UploadValidationFailure) -> NoRetur
|
|||
param="file",
|
||||
code=413,
|
||||
)
|
||||
case UploadedFileExtensionNotAllowed(extension=extension):
|
||||
raise ProxyException(
|
||||
message=(
|
||||
(f"File extension '{extension}'" if extension else "A file without an extension")
|
||||
+ " is not in this proxy's allowed_file_extensions setting. "
|
||||
"The file was not forwarded to the provider."
|
||||
),
|
||||
type="invalid_request_error",
|
||||
param="file",
|
||||
code=400,
|
||||
)
|
||||
case UploadedFileBlockedExtension(extension=extension):
|
||||
raise ProxyException(
|
||||
message=(
|
||||
|
|
|
|||
|
|
@ -7065,6 +7065,9 @@ class ProxyConfig:
|
|||
if "max_file_size_mb" not in self._yaml_general_settings_keys:
|
||||
general_settings["max_file_size_mb"] = _general_settings.get("max_file_size_mb")
|
||||
|
||||
if "allowed_file_extensions" not in self._yaml_general_settings_keys:
|
||||
general_settings["allowed_file_extensions"] = _general_settings.get("allowed_file_extensions")
|
||||
|
||||
if "blocked_file_extensions" not in self._yaml_general_settings_keys:
|
||||
general_settings["blocked_file_extensions"] = _general_settings.get("blocked_file_extensions")
|
||||
|
||||
|
|
@ -17036,6 +17039,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
|
|||
"max_request_size_mb": "Integer",
|
||||
"max_batch_file_size_mb": "Integer",
|
||||
"max_file_size_mb": "Integer",
|
||||
"allowed_file_extensions": "List",
|
||||
"blocked_file_extensions": "List",
|
||||
"max_response_size_mb": "Integer",
|
||||
"proxy_config_reload_interval_seconds": "Integer",
|
||||
|
|
|
|||
|
|
@ -4703,6 +4703,109 @@ def test_create_file_blocked_extension_unset_allows_everything(monkeypatch, llm_
|
|||
assert len(forwarded_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
["payload.exe", "notes.txt", "README"],
|
||||
ids=["other_extension", "text_extension", "no_extension"],
|
||||
)
|
||||
def test_create_file_extension_outside_allowlist_rejected_before_forwarding(
|
||||
monkeypatch, llm_router: Router, filename: str
|
||||
):
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
|
||||
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".jsonl"])
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/v1/files",
|
||||
files={"file": (filename, b"MZ\x90\x00", "application/octet-stream")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
finally:
|
||||
_teardown_batch_upload_endpoint()
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
error = response.json()["error"]
|
||||
assert error["type"] == "invalid_request_error"
|
||||
assert error["param"] == "file"
|
||||
assert "allowed_file_extensions" in error["message"]
|
||||
assert forwarded_calls == []
|
||||
|
||||
|
||||
def test_create_file_allowed_extension_forwards_case_insensitively(monkeypatch, llm_router: Router):
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
|
||||
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".JSONL"])
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
finally:
|
||||
_teardown_batch_upload_endpoint()
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(forwarded_calls) == 1
|
||||
|
||||
|
||||
def test_create_file_empty_allowlist_rejects_every_upload(monkeypatch, llm_router: Router):
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
|
||||
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [])
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
finally:
|
||||
_teardown_batch_upload_endpoint()
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert "allowed_file_extensions" in response.json()["error"]["message"]
|
||||
assert forwarded_calls == []
|
||||
|
||||
|
||||
def test_create_file_allowlist_runs_before_blocklist(monkeypatch, llm_router: Router):
|
||||
"""An extension in both lists is refused by the allowlist message, and the blocklist still holds on its own."""
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
|
||||
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".jsonl"])
|
||||
monkeypatch.setitem(ps.general_settings, "blocked_file_extensions", [".exe", ".jsonl"])
|
||||
|
||||
try:
|
||||
denied_by_allowlist = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("payload.exe", b"MZ\x90\x00", "application/octet-stream")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
denied_by_blocklist = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
finally:
|
||||
_teardown_batch_upload_endpoint()
|
||||
|
||||
assert denied_by_allowlist.status_code == 400, denied_by_allowlist.text
|
||||
assert "allowed_file_extensions" in denied_by_allowlist.json()["error"]["message"]
|
||||
assert denied_by_blocklist.status_code == 400, denied_by_blocklist.text
|
||||
assert "blocked_file_extensions" in denied_by_blocklist.json()["error"]["message"]
|
||||
assert forwarded_calls == []
|
||||
|
||||
|
||||
def test_create_file_path_traversal_filename_rejected_before_forwarding(monkeypatch, llm_router: Router):
|
||||
"""A filename carrying a directory-traversal component must never reach storage or the provider."""
|
||||
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -6,11 +7,14 @@ from litellm.proxy._types import ProxyException
|
|||
from litellm.proxy.openai_files_endpoints.general_upload_validation import (
|
||||
MB,
|
||||
UploadedFileBlockedExtension,
|
||||
UploadedFileExtensionNotAllowed,
|
||||
UploadedFileTooLarge,
|
||||
UploadedFileUnsafeFilename,
|
||||
check_allowed_extension,
|
||||
check_blocked_extension,
|
||||
check_unsafe_filename,
|
||||
check_upload_file_size,
|
||||
coerce_optional_str_list_setting,
|
||||
raise_upload_validation_failure,
|
||||
)
|
||||
|
||||
|
|
@ -79,6 +83,45 @@ def test_no_filename_skips_extension_check():
|
|||
assert check_blocked_extension(None, (".exe",)) is None
|
||||
|
||||
|
||||
def test_allowed_extension_passes():
|
||||
assert check_allowed_extension("batch.jsonl", (".jsonl", ".pdf")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["payload.exe", "notes.txt", "archive.tar.gz"])
|
||||
def test_extension_outside_allowlist_rejected(filename):
|
||||
assert check_allowed_extension(filename, (".jsonl", ".pdf")) == UploadedFileExtensionNotAllowed(
|
||||
extension=Path(filename).suffix
|
||||
)
|
||||
|
||||
|
||||
def test_allowed_extension_match_is_case_insensitive_for_upload():
|
||||
assert check_allowed_extension("batch.JSONL", (".jsonl",)) is None
|
||||
|
||||
|
||||
def test_allowed_extension_match_is_case_insensitive_for_configured_value():
|
||||
assert check_allowed_extension("batch.jsonl", (".JSONL",)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["README", "", None, "../../"])
|
||||
def test_no_extension_rejected_when_allowlist_set(filename):
|
||||
"""The allowlist grants by extension, so a name that yields none has nothing to be granted for."""
|
||||
assert check_allowed_extension(filename, (".jsonl",)) == UploadedFileExtensionNotAllowed(extension="")
|
||||
|
||||
|
||||
def test_empty_allowlist_rejects_everything():
|
||||
assert check_allowed_extension("batch.jsonl", ()) == UploadedFileExtensionNotAllowed(extension=".jsonl")
|
||||
|
||||
|
||||
def test_unset_allowlist_skips_check():
|
||||
assert check_allowed_extension("payload.exe", None) is None
|
||||
|
||||
|
||||
def test_coerce_str_list_setting_keeps_unset_and_empty_distinct():
|
||||
assert coerce_optional_str_list_setting(None) is None
|
||||
assert coerce_optional_str_list_setting([]) == ()
|
||||
assert coerce_optional_str_list_setting([".jsonl"]) == (".jsonl",)
|
||||
|
||||
|
||||
def test_path_traversal_filename_rejected():
|
||||
assert check_unsafe_filename("../../etc/passwd") == UploadedFileUnsafeFilename(filename="../../etc/passwd")
|
||||
|
||||
|
|
@ -112,6 +155,16 @@ def test_ordinary_filenames_allowed(filename):
|
|||
"413",
|
||||
("15.0 MB", "max_file_size_mb", "10 MB", "not forwarded"),
|
||||
),
|
||||
(
|
||||
UploadedFileExtensionNotAllowed(extension=".exe"),
|
||||
"400",
|
||||
(".exe", "allowed_file_extensions", "not forwarded"),
|
||||
),
|
||||
(
|
||||
UploadedFileExtensionNotAllowed(extension=""),
|
||||
"400",
|
||||
("without an extension", "allowed_file_extensions", "not forwarded"),
|
||||
),
|
||||
(
|
||||
UploadedFileBlockedExtension(extension=".exe"),
|
||||
"400",
|
||||
|
|
|
|||
|
|
@ -3469,6 +3469,30 @@ async def test_ProxyConfig__update_general_settings_cleared_db_max_batch_file_si
|
|||
assert ps.general_settings.get("max_batch_file_size_mb") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_applies_db_allowed_file_extensions(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
pc = ProxyConfig()
|
||||
await pc._update_general_settings({"allowed_file_extensions": [".jsonl"]})
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
assert ps.general_settings.get("allowed_file_extensions") == [".jsonl"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions_wins_over_db(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"allowed_file_extensions": [".pdf"]},
|
||||
)
|
||||
pc = ProxyConfig()
|
||||
pc._yaml_general_settings_keys = {"allowed_file_extensions"}
|
||||
await pc._update_general_settings({"allowed_file_extensions": [".jsonl"]})
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
assert ps.general_settings.get("allowed_file_extensions") == [".pdf"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_none_input_noop():
|
||||
pc = ProxyConfig()
|
||||
|
|
|
|||
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25809,6 +25809,11 @@ export interface components {
|
|||
* @description If True, lets keys address Responses API ids that this proxy did not issue (raw provider ids, or ids issued before response-id encryption was configured). Such an id carries no owner, so no ownership check can run on it; ids this proxy did issue keep full ownership enforcement. Off by default, in which case an unrecognized response id is rejected with 403
|
||||
*/
|
||||
allow_unmanaged_response_ids?: boolean | null;
|
||||
/**
|
||||
* Allowed File Extensions
|
||||
* @description the only file extensions (e.g. ['.jsonl', '.pdf', '.txt']) accepted on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Files with any other extension, or none, are rejected. An empty list rejects every upload. Unset means no allowlist is applied
|
||||
*/
|
||||
allowed_file_extensions?: string[] | null;
|
||||
/**
|
||||
* Allowed Routes
|
||||
* @description Proxy API Endpoints you want users to be able to access
|
||||
|
|
@ -25831,7 +25836,7 @@ export interface components {
|
|||
background_health_checks?: boolean | null;
|
||||
/**
|
||||
* Blocked File Extensions
|
||||
* @description file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename
|
||||
* @description file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Deprecated in favour of allowed_file_extensions; still enforced, after the allowlist, when set
|
||||
*/
|
||||
blocked_file_extensions?: string[] | null;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue