Skip to content

Commit a14ac62

Browse files
committed
address comments
Signed-off-by: Lucas <lyoon@redhat.com>
1 parent 03470cb commit a14ac62

3 files changed

Lines changed: 141 additions & 11 deletions

File tree

src/app/endpoints/vector_stores.py

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import os
55
import traceback
6-
from io import BytesIO
76
from typing import Annotated, Any
87

98
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
@@ -14,6 +13,7 @@
1413
from authorization.middleware import authorize
1514
from client import AsyncLlamaStackClientHolder
1615
from configuration import configuration
16+
from constants import DEFAULT_MAX_FILE_UPLOAD_SIZE
1717
from log import get_logger
1818
from models.config import Action
1919
from models.requests import (
@@ -22,6 +22,7 @@
2222
VectorStoreUpdateRequest,
2323
)
2424
from models.responses import (
25+
AbstractErrorResponse,
2526
FileResponse,
2627
ForbiddenResponse,
2728
InternalServerErrorResponse,
@@ -63,6 +64,7 @@
6364

6465
file_responses: dict[int | str, dict[str, Any]] = {
6566
200: FileResponse.openapi_response(),
67+
400: {"description": "Bad Request - Invalid file upload"},
6668
401: UnauthorizedResponse.openapi_response(
6769
examples=["missing header", "missing token"]
6870
),
@@ -434,22 +436,69 @@ async def create_file(
434436
435437
Raises:
436438
HTTPException:
437-
- 400: Bad request (e.g., file too large)
439+
- 400: Bad request (e.g., file too large, invalid format)
438440
- 401: Authentication failed
439441
- 403: Authorization failed
440442
- 500: Lightspeed Stack configuration not loaded
441443
- 503: Unable to connect to Llama Stack
442444
"""
443445
_ = auth
444-
_ = request
445446

446447
check_configuration_loaded(configuration)
447448

449+
# Check Content-Length header BEFORE reading to prevent DoS via memory exhaustion
450+
content_length = request.headers.get("content-length")
451+
if content_length:
452+
try:
453+
size = int(content_length)
454+
if size > DEFAULT_MAX_FILE_UPLOAD_SIZE:
455+
response = AbstractErrorResponse(
456+
response="File too large",
457+
cause=(
458+
f"File size {size} bytes exceeds maximum allowed "
459+
f"size of {DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes "
460+
f"({DEFAULT_MAX_FILE_UPLOAD_SIZE // (1024 * 1024)} MB)"
461+
),
462+
status_code=status.HTTP_400_BAD_REQUEST,
463+
)
464+
raise HTTPException(**response.model_dump())
465+
except ValueError:
466+
# Invalid Content-Length header, continue and validate after reading
467+
pass
468+
469+
# file.size attribute if available
470+
if hasattr(file, "size") and file.size is not None:
471+
if file.size > DEFAULT_MAX_FILE_UPLOAD_SIZE:
472+
response = AbstractErrorResponse(
473+
response="File too large",
474+
cause=(
475+
f"File size {file.size} bytes exceeds maximum allowed "
476+
f"size of {DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes "
477+
f"({DEFAULT_MAX_FILE_UPLOAD_SIZE // (1024 * 1024)} MB)"
478+
),
479+
status_code=status.HTTP_400_BAD_REQUEST,
480+
)
481+
raise HTTPException(**response.model_dump())
482+
448483
try:
449484
client = AsyncLlamaStackClientHolder().get_client()
450485

451-
# Read file content
486+
# Read file content once
452487
content = await file.read()
488+
489+
# Verify actual size after reading
490+
if len(content) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
491+
response = AbstractErrorResponse(
492+
response="File too large",
493+
cause=(
494+
f"File content size {len(content)} bytes exceeds maximum "
495+
f"allowed size of {DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes "
496+
f"({DEFAULT_MAX_FILE_UPLOAD_SIZE // (1024 * 1024)} MB)"
497+
),
498+
status_code=status.HTTP_400_BAD_REQUEST,
499+
)
500+
raise HTTPException(**response.model_dump())
501+
453502
filename = file.filename or "uploaded_file"
454503

455504
# Add .txt extension if no extension present
@@ -463,10 +512,12 @@ async def create_file(
463512
len(content),
464513
)
465514

466-
# Convert to BytesIO for Llama Stack client
467-
# The client expects bytes, io.IOBase, PathLike, or a tuple
515+
# BytesIO wraps the bytes object for library client compatibility
516+
# Note: BytesIO doesn't copy the data, it creates a file-like view
517+
from io import BytesIO
518+
468519
file_bytes = BytesIO(content)
469-
file_bytes.name = filename # Set the filename attribute
520+
file_bytes.name = filename
470521

471522
file_obj = await client.files.create(
472523
file=file_bytes,
@@ -487,7 +538,13 @@ async def create_file(
487538
raise HTTPException(**response.model_dump()) from e
488539
except BadRequestError as e:
489540
logger.error("Bad request for file upload: %s", e)
490-
response = ServiceUnavailableResponse(backend_name="Llama Stack", cause=str(e))
541+
# BadRequestError from Llama Stack indicates client error (e.g., file too large)
542+
# Map to 400 Bad Request, not 503 Service Unavailable
543+
response = AbstractErrorResponse(
544+
response="Invalid file upload",
545+
cause=f"File upload rejected by Llama Stack: {str(e)}",
546+
status_code=status.HTTP_400_BAD_REQUEST,
547+
)
491548
raise HTTPException(**response.model_dump()) from e
492549
except Exception as e:
493550
full_trace = traceback.format_exc()
@@ -572,9 +629,12 @@ async def add_file_to_vector_store(
572629
else:
573630
raise # Re-raise if not a lock error or max retries reached
574631
if not vs_file:
575-
raise HTTPException(
576-
status_code=500, detail="Failed to create vector store file"
632+
# Use standard error response model for consistency
633+
response = InternalServerErrorResponse(
634+
response="Failed to create vector store file",
635+
cause="All retry attempts failed to create the vector store file",
577636
)
637+
raise HTTPException(**response.model_dump())
578638
logger.info(
579639
"Vector store file created - ID: %s, status: %s, last_error: %s",
580640
vs_file.id,

src/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@
128128
DEFAULT_AUTHENTICATION_MODULE = AUTH_MOD_NOOP
129129
# Maximum allowed size for base64-encoded x-rh-identity header (bytes)
130130
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE = 8192
131+
132+
# Maximum allowed file upload size (bytes) - 100MB default
133+
# Protects against DoS attacks via large file uploads
134+
DEFAULT_MAX_FILE_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
131135
DEFAULT_JWT_UID_CLAIM = "user_id"
132136
DEFAULT_JWT_USER_NAME_CLAIM = "username"
133137

tests/unit/app/endpoints/test_vector_stores.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ async def test_create_file_success(mocker: MockerFixture) -> None:
402402
# Mock UploadFile
403403
mock_file = mocker.AsyncMock()
404404
mock_file.filename = "test.txt"
405+
mock_file.size = 12 # Size of "test content"
405406
mock_file.read.return_value = b"test content"
406407

407408
response = await create_file(request=request, auth=auth, file=mock_file)
@@ -821,6 +822,7 @@ async def test_create_file_connection_error(mocker: MockerFixture) -> None:
821822

822823
mock_file = mocker.AsyncMock()
823824
mock_file.filename = "test.txt"
825+
mock_file.size = 12 # Size of "test content"
824826
mock_file.read.return_value = b"test content"
825827

826828
with pytest.raises(HTTPException) as e:
@@ -854,11 +856,75 @@ async def test_create_file_bad_request(mocker: MockerFixture) -> None:
854856

855857
mock_file = mocker.AsyncMock()
856858
mock_file.filename = "test.txt"
859+
mock_file.size = 12 # Size of "test content"
857860
mock_file.read.return_value = b"test content"
858861

859862
with pytest.raises(HTTPException) as e:
860863
await create_file(request=request, auth=auth, file=mock_file)
861-
assert e.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
864+
865+
assert e.value.status_code == status.HTTP_400_BAD_REQUEST
866+
867+
868+
@pytest.mark.asyncio
869+
async def test_create_file_too_large(mocker: MockerFixture) -> None:
870+
"""Test create file with file size exceeding limit."""
871+
mock_authorization_resolvers(mocker)
872+
873+
config_dict = get_test_config()
874+
cfg = AppConfig()
875+
cfg.init_from_dict(config_dict)
876+
877+
mocker.patch("app.endpoints.vector_stores.configuration", cfg)
878+
879+
request = get_test_request()
880+
auth = get_test_auth()
881+
882+
# Create a mock file that exceeds the size limit
883+
mock_file = mocker.AsyncMock()
884+
mock_file.filename = "large_file.pdf"
885+
mock_file.size = 200 * 1024 * 1024 # 200 MB (exceeds 100 MB limit)
886+
mock_file.read.return_value = b"x" * (200 * 1024 * 1024)
887+
888+
with pytest.raises(HTTPException) as e:
889+
await create_file(request=request, auth=auth, file=mock_file)
890+
891+
assert e.value.status_code == status.HTTP_400_BAD_REQUEST
892+
assert "too large" in str(e.value.detail).lower()
893+
894+
895+
@pytest.mark.asyncio
896+
async def test_create_file_content_length_too_large(mocker: MockerFixture) -> None:
897+
"""Test create file with Content-Length header exceeding limit."""
898+
mock_authorization_resolvers(mocker)
899+
900+
config_dict = get_test_config()
901+
cfg = AppConfig()
902+
cfg.init_from_dict(config_dict)
903+
904+
mocker.patch("app.endpoints.vector_stores.configuration", cfg)
905+
906+
# Create request with large Content-Length header
907+
request = Request(
908+
scope={
909+
"type": "http",
910+
"headers": [
911+
(b"authorization", b"Bearer test-token"),
912+
(b"content-length", b"209715200"), # 200 MB
913+
],
914+
}
915+
)
916+
auth = get_test_auth()
917+
918+
# Create a mock file
919+
mock_file = mocker.AsyncMock()
920+
mock_file.filename = "large_file.pdf"
921+
mock_file.size = None # No size attribute
922+
923+
with pytest.raises(HTTPException) as e:
924+
await create_file(request=request, auth=auth, file=mock_file)
925+
926+
assert e.value.status_code == status.HTTP_400_BAD_REQUEST
927+
assert "too large" in str(e.value.detail).lower()
862928

863929

864930
@pytest.mark.asyncio

0 commit comments

Comments
 (0)