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:
yassin 2026-09-14 18:53:29 +00:00
parent cab1e113f7
commit 82ef6ea6ab
8 changed files with 254 additions and 17 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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=(

View file

@ -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",

View file

@ -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)

View file

@ -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",

View file

@ -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()

View file

@ -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;
/**