fix(files): opt-out flag, bounded read, and fail-closed source for the vector store upload controls

This commit is contained in:
mateo-berri 2026-09-25 17:52:14 -07:00
parent cc26342a3a
commit 328fe2aec2
9 changed files with 219 additions and 55 deletions

View file

@ -2767,7 +2767,8 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
description=(
"controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose "
"assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> "
"the way custom_auth does, and unset runs the EICAR test scanner - https://docs.litellm.ai/docs/rag_ingest"
"the way custom_auth does, and unset runs the EICAR test scanner; rag_ingest.files_api_controls: false lets "
"the /v1/files uploads through unchecked - https://docs.litellm.ai/docs/rag_ingest"
),
)
max_parallel_requests: int | None = Field(

View file

@ -94,7 +94,12 @@ from litellm.proxy.openai_files_endpoints.general_upload_validation import (
coerce_optional_str_list_setting,
raise_upload_validation_failure,
)
from litellm.proxy.rag_endpoints.upload_security import MalwareScanner, RejectedUpload, validate_upload
from litellm.proxy.rag_endpoints.upload_security import (
MAX_UPLOAD_SIZE_BYTES,
MalwareScanner,
RejectedUpload,
validate_upload,
)
from litellm.proxy.utils import PrismaClient, ProxyLogging, is_known_model
from litellm.repositories.table_repositories import ManagedFileRepository
from litellm.router import Router
@ -105,6 +110,7 @@ from litellm.types.llms.openai import (
OpenAIFileObject,
OpenAIFilesPurpose,
)
from litellm.types.proxy.rag_ingest import RagIngestSettings
router: Final = APIRouter()
@ -531,14 +537,33 @@ async def route_create_file(
_VECTOR_STORE_UPLOAD_PURPOSES: Final[frozenset[str]] = frozenset({"assistants", "user_data"})
async def _reject_unsafe_vector_store_upload(
purpose: str,
file_source: bytes | BinaryIO,
scanner: MalwareScanner,
) -> None:
if purpose not in _VECTOR_STORE_UPLOAD_PURPOSES or not isinstance(file_source, bytes):
return
validation: Final = await asyncio.to_thread(validate_upload, content=file_source, scanner=scanner)
def _vector_store_upload_controls_apply(purpose: str, settings: RagIngestSettings) -> bool:
return purpose in _VECTOR_STORE_UPLOAD_PURPOSES and settings.files_api_controls
def _upload_read_limit_bytes(max_file_size_mb: int | None, controls_apply: bool) -> int | None:
control_limit: Final = MAX_UPLOAD_SIZE_BYTES + 1 if controls_apply else None
if max_file_size_mb is None or max_file_size_mb <= 0:
return control_limit
admin_limit: Final = max_file_size_mb * MB + 1
return admin_limit if control_limit is None else min(admin_limit, control_limit)
def _in_memory_upload(file_source: bytes | BinaryIO) -> bytes:
if isinstance(file_source, bytes):
return file_source
raise ProxyException(
message="Uploads with purpose assistants or user_data must be read into memory before the upload controls run.",
type=ProxyErrorTypes.internal_server_error.value,
param="file",
code=500,
)
async def _reject_unsafe_vector_store_upload(content: bytes, scanner: MalwareScanner) -> None:
validation: Final = await asyncio.to_thread(
validate_upload, content=content, scanner=scanner, max_size_bytes=MAX_UPLOAD_SIZE_BYTES
)
if isinstance(validation, RejectedUpload):
raise ProxyException(
message=f"{validation.message} Rejection reason: {validation.reason.value}.",
@ -598,6 +623,7 @@ async def create_file(
llm_router,
proxy_config,
proxy_logging_obj,
rag_ingest_settings,
rag_upload_malware_scanner,
version,
)
@ -613,18 +639,21 @@ async def create_file(
raise_upload_validation_failure(unsafe_filename_failure)
max_file_size_mb: Final = coerce_optional_int_setting(general_settings.get("max_file_size_mb"))
controls_apply: Final = _vector_store_upload_controls_apply(purpose, rag_ingest_settings)
read_limit_bytes: Final = _upload_read_limit_bytes(max_file_size_mb, controls_apply)
# Batch uploads can be gigabytes. Starlette has already spooled the upload
# to disk, so stream from that handle instead of reading it into memory.
# Other uploads stay in-memory bytes, bounded to max_file_size_mb (plus one
# byte, to still tell "exactly at the limit" from "over it") when it is set,
# so an oversized upload cannot be read to completion before it is rejected.
# Other uploads stay in-memory bytes, bounded to the smaller of max_file_size_mb
# and the vector store cap (plus one byte, to still tell "exactly at the limit"
# from "over it") when either applies, so an oversized upload cannot be read to
# completion before it is rejected.
file_source: bytes | BinaryIO
if purpose == "batch":
await file.seek(0)
file_source = file.file
elif max_file_size_mb is not None and max_file_size_mb > 0:
file_source = await file.read(max_file_size_mb * MB + 1)
elif read_limit_bytes is not None:
file_source = await file.read(read_limit_bytes)
else:
file_source = await file.read()
custom_llm_provider = (
@ -677,7 +706,8 @@ async def create_file(
if blocked_extension_failure is not None:
raise_upload_validation_failure(blocked_extension_failure)
await _reject_unsafe_vector_store_upload(purpose, file_source, rag_upload_malware_scanner)
if controls_apply:
await _reject_unsafe_vector_store_upload(_in_memory_upload(file_source), rag_upload_malware_scanner)
if passthrough:
_validate_passthrough_upload(

View file

@ -745,7 +745,8 @@ from litellm.proxy.rag_endpoints.endpoints import router as rag_router
from litellm.proxy.rag_endpoints.upload_security import (
EicarTestMalwareScanner,
MalwareScanner,
MalwareScannerConfigError,
RagIngestConfigError,
parse_rag_ingest_settings,
resolve_malware_scanner,
)
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
@ -867,6 +868,7 @@ from litellm.types.proxy.model_deprecation import (
DEFAULT_DEPRECATION_WARN_DAYS,
ModelDeprecationResponse,
)
from litellm.types.proxy.rag_ingest import RagIngestSettings
from litellm.types.proxy.spend_capture_rate import SpendCaptureProvider, SpendCaptureRateCheckSettings
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import (
@ -1042,6 +1044,7 @@ def cleanup_router_config_variables():
otel_logging, \
user_custom_auth, \
rag_upload_malware_scanner, \
rag_ingest_settings, \
user_custom_auth_path, \
user_custom_key_generate, \
user_custom_key_update, \
@ -1061,6 +1064,7 @@ def cleanup_router_config_variables():
otel_logging = None
user_custom_auth = None
rag_upload_malware_scanner = EicarTestMalwareScanner()
rag_ingest_settings = RagIngestSettings()
user_custom_auth_path = None
user_custom_key_generate = None
user_custom_key_update = None
@ -2573,6 +2577,7 @@ native_background_mode: list[str] = [] # Models that should use native provider
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
user_custom_auth = None
rag_upload_malware_scanner: MalwareScanner = EicarTestMalwareScanner()
rag_ingest_settings: RagIngestSettings = RagIngestSettings()
user_custom_key_generate = None
# Sentinel: prevents PKCE-no-Redis advisory from re-logging on config hot-reload.
# Tests that need to reset it can patch 'litellm.proxy.proxy_server._pkce_no_redis_warning_emitted'.
@ -5950,6 +5955,7 @@ class ProxyConfig:
otel_logging, \
user_custom_auth, \
rag_upload_malware_scanner, \
rag_ingest_settings, \
user_custom_auth_path, \
user_custom_key_generate, \
user_custom_key_update, \
@ -6366,13 +6372,17 @@ class ProxyConfig:
TypeAdapter(MCPAdvertisedVersions).validate_python(general_settings["mcp_advertised_versions"])
parsed_rag_ingest_settings: Final = parse_rag_ingest_settings(general_settings.get("rag_ingest"))
if isinstance(parsed_rag_ingest_settings, RagIngestConfigError):
raise ValueError(parsed_rag_ingest_settings.message)
resolved_malware_scanner: Final = resolve_malware_scanner(
general_settings.get("rag_ingest"),
parsed_rag_ingest_settings,
config_file_path=config_file_path,
load_instance=get_instance_fn,
)
if isinstance(resolved_malware_scanner, MalwareScannerConfigError):
if isinstance(resolved_malware_scanner, RagIngestConfigError):
raise ValueError(resolved_malware_scanner.message)
rag_ingest_settings = parsed_rag_ingest_settings
rag_upload_malware_scanner = resolved_malware_scanner
if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None:

View file

@ -123,39 +123,42 @@ class EicarTestMalwareScanner:
@dataclass(frozen=True, slots=True)
class MalwareScannerConfigError:
class RagIngestConfigError:
message: str
InstanceLoader: TypeAlias = Callable[[str, str | None], object]
def parse_rag_ingest_settings(rag_ingest: object) -> RagIngestSettings | RagIngestConfigError:
if rag_ingest is None:
return RagIngestSettings()
try:
return RagIngestSettings.model_validate(rag_ingest)
except ValidationError as e:
return RagIngestConfigError(f"general_settings.rag_ingest is invalid: {e}")
def resolve_malware_scanner(
rag_ingest: object,
settings: RagIngestSettings,
*,
config_file_path: str | None,
load_instance: InstanceLoader,
) -> MalwareScanner | MalwareScannerConfigError:
try:
settings: Final = RagIngestSettings() if rag_ingest is None else RagIngestSettings.model_validate(rag_ingest)
except ValidationError as e:
return MalwareScannerConfigError(f"general_settings.rag_ingest is invalid: {e}")
) -> MalwareScanner | RagIngestConfigError:
if settings.malware_scanner is None:
return EicarTestMalwareScanner()
try:
loaded: Final = load_instance(settings.malware_scanner, config_file_path)
except Exception as e:
return MalwareScannerConfigError(
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} could not be loaded: {e}"
)
return RagIngestConfigError(f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} could not be loaded: {e}")
if isinstance(loaded, type):
return MalwareScannerConfigError(
return RagIngestConfigError(
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} names the class {loaded.__qualname__}; "
"name an instance of it instead"
)
if isinstance(loaded, MalwareScanner) and callable(loaded.scan):
return loaded
return MalwareScannerConfigError(
return RagIngestConfigError(
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} resolved to a {type(loaded).__qualname__}, "
"which has no scan(content: bytes) -> ScanResult method"
)

View file

@ -7,7 +7,7 @@ class RagIngestSettings(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
malware_scanner: str | None = Field(
None,
default=None,
description=(
"The scanner every vector-store upload goes through, as <module>.<instance> where the module sits "
"next to config.yaml or is importable, resolved the way custom_auth is. The instance must expose "
@ -15,3 +15,12 @@ class RagIngestSettings(BaseModel):
"the EICAR test scanner, which flags only the EICAR test file"
),
)
files_api_controls: bool = Field(
default=True,
description=(
"Whether /v1/files uploads with purpose assistants or user_data run the upload controls (format, size "
"and malware checks) before reaching the provider. false lets those uploads through unchecked, for a "
"deployment that sends the provider a format the controls reject on that route, such as DOCX or "
"images. /v1/rag/ingest runs the controls whatever this is set to"
),
)

View file

@ -18,7 +18,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ui_view_u
from litellm.proxy.openai_files_endpoints.file_content_streaming_handler import (
FileContentStreamingHandler,
)
from litellm.proxy.openai_files_endpoints.general_upload_validation import MB
from litellm.proxy.proxy_server import app
from litellm.proxy.rag_endpoints.upload_security import MAX_UPLOAD_SIZE_BYTES
from litellm.types.llms.openai import (
FileListPage,
HttpxBinaryResponseContent,
@ -6033,3 +6035,70 @@ def test_create_file_other_purposes_skip_the_vector_store_upload_controls(
)
assert resp.status_code == 200, resp.text
assert captured["file"][0] == name
def test_create_file_for_vector_store_purposes_lets_uploads_through_when_files_api_controls_is_off(
monkeypatch, llm_router: Router
):
from litellm.proxy.rag_endpoints.upload_security import ScanResult, ScanVerdict
from litellm.types.proxy.rag_ingest import RagIngestSettings
class _InfectedScanner:
def scan(self, content: bytes) -> ScanResult:
return ScanResult(verdict=ScanVerdict.INFECTED, signature="Custom.Sig")
monkeypatch.setattr("litellm.proxy.proxy_server.rag_upload_malware_scanner", _InfectedScanner())
monkeypatch.setattr("litellm.proxy.proxy_server.rag_ingest_settings", RagIngestSettings(files_api_controls=False))
zip_bytes: Final = b"PK\x03\x04\x14\x00\x00\x00payload"
resp, captured = _post_file_as_admin(
monkeypatch, llm_router, purpose="assistants", name="deck.zip", content=zip_bytes, ctype="application/zip"
)
assert resp.status_code == 200, resp.text
assert captured["file"][0] == "deck.zip"
assert captured["file"][1] == zip_bytes
def test_create_file_for_vector_store_purposes_reads_at_most_the_cap_plus_one_byte(monkeypatch, llm_router: Router):
from litellm.proxy.openai_files_endpoints import files_endpoints as fe
monkeypatch.setattr(fe, "MAX_UPLOAD_SIZE_BYTES", 10)
resp, captured = _post_file_as_admin(
monkeypatch, llm_router, purpose="assistants", name="notes.txt", content=b"x" * 20, ctype="text/plain"
)
assert resp.status_code == 400, resp.text
message = resp.json()["error"]["message"]
assert "Uploaded file is 11 bytes" in message
assert "Rejection reason: file_too_large." in message
assert captured == {}
@pytest.mark.parametrize(
"max_file_size_mb, controls_apply, expected",
[
(None, True, MAX_UPLOAD_SIZE_BYTES + 1),
(0, True, MAX_UPLOAD_SIZE_BYTES + 1),
(1, True, MB + 1),
(1024, True, MAX_UPLOAD_SIZE_BYTES + 1),
(1, False, MB + 1),
(None, False, None),
],
)
def test_upload_read_limit_is_the_smaller_of_the_admin_limit_and_the_vector_store_cap(
max_file_size_mb, controls_apply, expected
):
from litellm.proxy.openai_files_endpoints.files_endpoints import _upload_read_limit_bytes
assert _upload_read_limit_bytes(max_file_size_mb, controls_apply) == expected
def test_vector_store_upload_controls_refuse_a_source_that_was_not_read_into_memory():
from io import BytesIO
from litellm.proxy._types import ProxyException
from litellm.proxy.openai_files_endpoints.files_endpoints import _in_memory_upload
assert _in_memory_upload(b"in memory") == b"in memory"
with pytest.raises(ProxyException) as raised:
_in_memory_upload(BytesIO(b"spooled to disk"))
assert raised.value.code == "500"
assert raised.value.param == "file"

View file

@ -15,7 +15,7 @@ from litellm.proxy.rag_endpoints.upload_security import (
EICAR_TEST_SIGNATURE,
DetectedFormat,
EicarTestMalwareScanner,
MalwareScannerConfigError,
RagIngestConfigError,
RejectedUpload,
RejectionReason,
ScanResult,
@ -23,10 +23,12 @@ from litellm.proxy.rag_endpoints.upload_security import (
SecuredUpload,
generate_safe_filename,
inspect_content,
parse_rag_ingest_settings,
resolve_malware_scanner,
safe_download_headers,
validate_upload,
)
from litellm.types.proxy.rag_ingest import RagIngestSettings
@dataclass(frozen=True)
@ -196,20 +198,47 @@ def _loader_raising(error: Exception):
return load_instance
def test_parse_rag_ingest_settings_unset_block_runs_every_control_with_the_eicar_scanner():
for rag_ingest in (None, {}, {"malware_scanner": None}):
assert parse_rag_ingest_settings(rag_ingest) == RagIngestSettings(
malware_scanner=None, files_api_controls=True
), rag_ingest
def test_parse_rag_ingest_settings_reads_the_files_api_opt_out():
parsed = parse_rag_ingest_settings({"malware_scanner": "custom_scanner.scanner", "files_api_controls": False})
assert parsed == RagIngestSettings(malware_scanner="custom_scanner.scanner", files_api_controls=False)
@pytest.mark.parametrize(
"rag_ingest",
[
{"malware_scaner": "custom_scanner.scanner"},
{"malware_scanner": 42},
{"files_api_controls": "sometimes"},
"custom_scanner.scanner",
["x"],
],
)
def test_parse_rag_ingest_settings_rejects_an_invalid_block(rag_ingest):
parsed = parse_rag_ingest_settings(rag_ingest)
assert isinstance(parsed, RagIngestConfigError)
assert "general_settings.rag_ingest" in parsed.message
def test_resolve_malware_scanner_unset_runs_the_eicar_test_scanner():
load_instance, calls = _loader_returning(_CLEAN_SCANNER)
for rag_ingest in (None, {}, {"malware_scanner": None}):
resolved = resolve_malware_scanner(
rag_ingest, config_file_path="/etc/litellm/config.yaml", load_instance=load_instance
)
assert isinstance(resolved, EicarTestMalwareScanner), rag_ingest
resolved = resolve_malware_scanner(
RagIngestSettings(), config_file_path="/etc/litellm/config.yaml", load_instance=load_instance
)
assert isinstance(resolved, EicarTestMalwareScanner)
assert calls == []
def test_resolve_malware_scanner_loads_the_configured_instance_next_to_the_config():
load_instance, calls = _loader_returning(_INFECTED_SCANNER)
resolved = resolve_malware_scanner(
{"malware_scanner": "custom_scanner.scanner"},
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
config_file_path="/etc/litellm/config.yaml",
load_instance=load_instance,
)
@ -230,11 +259,11 @@ def test_resolve_malware_scanner_loads_the_configured_instance_next_to_the_confi
def test_resolve_malware_scanner_rejects_an_object_that_is_not_a_scanner(loaded, expected_fragment):
load_instance, _calls = _loader_returning(loaded)
resolved = resolve_malware_scanner(
{"malware_scanner": "custom_scanner.scanner"},
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
config_file_path=None,
load_instance=load_instance,
)
assert isinstance(resolved, MalwareScannerConfigError)
assert isinstance(resolved, RagIngestConfigError)
assert "general_settings.rag_ingest.malware_scanner" in resolved.message
assert "custom_scanner.scanner" in resolved.message
assert expected_fragment in resolved.message
@ -251,22 +280,11 @@ def test_resolve_malware_scanner_rejects_an_object_that_is_not_a_scanner(loaded,
)
def test_resolve_malware_scanner_reports_a_failed_load_with_the_option_name(error):
resolved = resolve_malware_scanner(
{"malware_scanner": "custom_scanner.scanner"},
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
config_file_path=None,
load_instance=_loader_raising(error),
)
assert isinstance(resolved, MalwareScannerConfigError)
assert isinstance(resolved, RagIngestConfigError)
assert "general_settings.rag_ingest.malware_scanner" in resolved.message
assert str(error) in resolved.message
@pytest.mark.parametrize(
"rag_ingest",
[{"malware_scaner": "custom_scanner.scanner"}, {"malware_scanner": 42}, "custom_scanner.scanner", ["x"]],
)
def test_resolve_malware_scanner_rejects_an_invalid_rag_ingest_block(rag_ingest):
load_instance, calls = _loader_returning(_CLEAN_SCANNER)
resolved = resolve_malware_scanner(rag_ingest, config_file_path=None, load_instance=load_instance)
assert isinstance(resolved, MalwareScannerConfigError)
assert "general_settings.rag_ingest" in resolved.message
assert calls == []

View file

@ -3522,8 +3522,10 @@ async def test_load_config_role_permissions_usable_by_jwt_auth(tmp_path):
async def test_load_config_wires_the_configured_malware_scanner_into_uploads(tmp_path, monkeypatch):
from litellm.proxy.proxy_server import ProxyConfig
from litellm.proxy.rag_endpoints.upload_security import EicarTestMalwareScanner, ScanVerdict
from litellm.types.proxy.rag_ingest import RagIngestSettings
monkeypatch.setattr(proxy_server_module, "rag_upload_malware_scanner", EicarTestMalwareScanner())
monkeypatch.setattr(proxy_server_module, "rag_ingest_settings", RagIngestSettings(files_api_controls=False))
(tmp_path / "boot_scanner.py").write_text(
"from litellm.proxy.rag_endpoints.upload_security import ScanResult, ScanVerdict\n"
"class FlagEverything:\n"
@ -3539,10 +3541,26 @@ async def test_load_config_wires_the_configured_malware_scanner_into_uploads(tmp
)
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert proxy_server_module.rag_upload_malware_scanner.scan(b"plain text").verdict is ScanVerdict.INFECTED
assert proxy_server_module.rag_ingest_settings.files_api_controls is True
config_file.write_text(
yaml.dump(
{
"model_list": [],
"general_settings": {
"rag_ingest": {"malware_scanner": "boot_scanner.scanner", "files_api_controls": False}
},
}
)
)
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert proxy_server_module.rag_ingest_settings.files_api_controls is False
assert proxy_server_module.rag_upload_malware_scanner.scan(b"plain text").verdict is ScanVerdict.INFECTED
config_file.write_text(yaml.dump({"model_list": []}))
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert isinstance(proxy_server_module.rag_upload_malware_scanner, EicarTestMalwareScanner)
assert proxy_server_module.rag_ingest_settings.files_api_controls is True
config_file.write_text(
yaml.dump(

View file

@ -28451,7 +28451,7 @@ export interface components {
* @default 30
*/
proxy_config_reload_interval_seconds: number;
/** @description controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> the way custom_auth does, and unset runs the EICAR test scanner - https://docs.litellm.ai/docs/rag_ingest */
/** @description controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> the way custom_auth does, and unset runs the EICAR test scanner; rag_ingest.files_api_controls: false lets the /v1/files uploads through unchecked - https://docs.litellm.ai/docs/rag_ingest */
rag_ingest?: components["schemas"]["RagIngestSettings"] | null;
/**
* Reject Clientside Metadata Tags
@ -39055,6 +39055,12 @@ export interface components {
};
/** RagIngestSettings */
RagIngestSettings: {
/**
* Files Api Controls
* @description Whether /v1/files uploads with purpose assistants or user_data run the upload controls (format, size and malware checks) before reaching the provider. false lets those uploads through unchecked, for a deployment that sends the provider a format the controls reject on that route, such as DOCX or images. /v1/rag/ingest runs the controls whatever this is set to
* @default true
*/
files_api_controls: boolean;
/**
* Malware Scanner
* @description The scanner every vector-store upload goes through, as <module>.<instance> where the module sits next to config.yaml or is importable, resolved the way custom_auth is. The instance must expose scan(content: bytes) -> ScanResult and be safe to call from several threads at once. Unset runs the EICAR test scanner, which flags only the EICAR test file