Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions tests/entrypoints/speech_to_text/test_upload_size_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for the speech-to-text upload size pre-check.

These tests verify that over-limit audio uploads are rejected *before*
the full file is materialized into memory, closing the vulnerability
where vLLM would allocate memory proportional to an oversized upload
before enforcing the VLLM_MAX_AUDIO_CLIP_FILESIZE_MB limit.
"""

from unittest.mock import AsyncMock, patch

import pytest

from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
from vllm.exceptions import VLLMValidationError


def _make_upload_file(data: bytes, *, size: int | None = None) -> AsyncMock:
"""Create a mock UploadFile that yields data in chunks."""
mock = AsyncMock()
mock.size = size

offset = 0

async def _read(n: int = -1):
nonlocal offset
if n <= 0:
chunk = data[offset:]
offset = len(data)
return chunk
chunk = data[offset : offset + n]
offset += len(chunk)
return chunk

mock.read = AsyncMock(side_effect=_read)
return mock


@pytest.mark.asyncio
async def test_rejects_oversized_upload_via_content_length():
"""File is rejected early when file.size exceeds the limit."""
max_mb = 1
oversized_bytes = max_mb * 1024 * 1024 + 1

upload = _make_upload_file(b"", size=oversized_bytes)

with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
await read_upload_with_limit(upload, max_size_mb=max_mb)

upload.read.assert_not_called()


@pytest.mark.asyncio
async def test_rejects_oversized_upload_via_chunked_read():
"""File is rejected mid-read without materializing the full content."""
max_mb = 1
max_bytes = max_mb * 1024 * 1024
oversized_data = b"\x00" * (max_bytes + 1024)

upload = _make_upload_file(oversized_data, size=None)

with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
await read_upload_with_limit(upload, max_size_mb=max_mb)


@pytest.mark.asyncio
async def test_accepts_file_within_limit():
"""File within the limit is read successfully."""
max_mb = 1
data = b"\x00" * (512 * 1024) # 512 KiB, well under 1 MB

upload = _make_upload_file(data, size=len(data))
result = await read_upload_with_limit(upload, max_size_mb=max_mb)

assert result == data


@pytest.mark.asyncio
async def test_accepts_file_at_exact_limit():
"""File exactly at the limit boundary is accepted."""
max_mb = 1
max_bytes = max_mb * 1024 * 1024
data = b"\x00" * max_bytes

upload = _make_upload_file(data, size=len(data))
result = await read_upload_with_limit(upload, max_size_mb=max_mb)

assert result == data


@pytest.mark.asyncio
async def test_rejects_at_one_byte_over_limit():
"""File one byte over the limit is rejected."""
max_mb = 1
max_bytes = max_mb * 1024 * 1024
data = b"\x00" * (max_bytes + 1)

upload = _make_upload_file(data, size=None)

with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
await read_upload_with_limit(upload, max_size_mb=max_mb)


@pytest.mark.asyncio
async def test_uses_env_default_when_no_limit_specified():
"""Uses VLLM_MAX_AUDIO_CLIP_FILESIZE_MB when max_size_mb is not given."""
with patch("vllm.entrypoints.speech_to_text.base.utils.envs") as mock_envs:
mock_envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB = 2
max_bytes = 2 * 1024 * 1024
oversized_data = b"\x00" * (max_bytes + 1)

upload = _make_upload_file(oversized_data, size=None)

with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
await read_upload_with_limit(upload)


@pytest.mark.asyncio
async def test_chunked_read_does_not_fully_materialize():
"""Verify that for large oversized files, we stop reading early.

The function reads in 64 KiB chunks and aborts once the accumulated
size exceeds the limit. We confirm that far fewer read calls were made
than would be required to fully materialize the file.
"""
max_mb = 1
max_bytes = max_mb * 1024 * 1024
large_size = max_bytes * 10 # 10x the limit
data = b"\x00" * large_size

upload = _make_upload_file(data, size=None)

with pytest.raises(VLLMValidationError):
await read_upload_with_limit(upload, max_size_mb=max_mb)

chunk_size = 64 * 1024
calls_for_full_read = large_size // chunk_size + 1
calls_to_exceed_limit = max_bytes // chunk_size + 1
actual_calls = upload.read.call_count
assert actual_calls <= calls_to_exceed_limit + 1
assert actual_calls < calls_for_full_read
64 changes: 64 additions & 0 deletions vllm/entrypoints/speech_to_text/base/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shared utilities for speech-to-text API routes."""

from fastapi import UploadFile

import vllm.envs as envs
from vllm.exceptions import VLLMValidationError
from vllm.utils.mem_constants import KiB_bytes, MiB_bytes

_READ_CHUNK_SIZE = 64 * KiB_bytes


async def read_upload_with_limit(
file: UploadFile,
max_size_mb: float | None = None,
) -> bytes:
"""Read an uploaded file enforcing a size limit *before* full
materialization.

The function first checks the Content-Length header (``file.size``) when
available. Regardless, it then performs a chunked read that stops as soon
as the accumulated bytes exceed the limit, ensuring that an oversized
upload never fully materializes in memory.

Args:
file: The FastAPI/Starlette ``UploadFile`` object.
max_size_mb: Maximum allowed compressed file size in megabytes.
Defaults to ``envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB``.

Returns:
The file content as ``bytes``.

Raises:
VLLMValidationError: If the file exceeds the configured size limit.
"""
if max_size_mb is None:
max_size_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB

max_bytes = int(max_size_mb * MiB_bytes)

if file.size is not None and file.size > max_bytes:
raise VLLMValidationError(
"Maximum file size exceeded",
parameter="audio_filesize_mb",
value=file.size / MiB_bytes,
)

chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_READ_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise VLLMValidationError(
"Maximum file size exceeded",
parameter="audio_filesize_mb",
value=total / MiB_bytes,
)
chunks.append(chunk)

return b"".join(chunks)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
load_aware_call,
with_cancellation,
)
from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
from vllm.logger import init_logger

from .protocol import TranscriptionRequest, TranscriptionResponseVariant
Expand Down Expand Up @@ -45,7 +46,7 @@ async def create_transcriptions(
if handler is None:
raise NotImplementedError("The model does not support Transcriptions API")

audio_data = await request.file.read()
audio_data = await read_upload_with_limit(request.file)

generator = await handler.create_transcription(audio_data, request, raw_request)

Expand Down
3 changes: 2 additions & 1 deletion vllm/entrypoints/speech_to_text/translation/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
load_aware_call,
with_cancellation,
)
from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
from vllm.logger import init_logger

from .protocol import TranslationRequest, TranslationResponseVariant
Expand Down Expand Up @@ -45,7 +46,7 @@ async def create_translations(
if handler is None:
raise NotImplementedError("The model does not support Translations API")

audio_data = await request.file.read()
audio_data = await read_upload_with_limit(request.file)

generator = await handler.create_translation(audio_data, request, raw_request)

Expand Down
Loading