Skip to content

Commit 73cdebe

Browse files
fix: enforce avatar upload size limit before buffering body (#220)
* fix: enforce avatar upload size limit before buffering body Reject oversized Content-Length headers and stream multipart uploads in fixed-size chunks so workers never hold an entire large file in memory. * fix: run async upload-limit tests outside pytest event loop Use a thread-backed helper so asyncio.run is not called while pytest already has an active event loop in CI.
1 parent 8cac940 commit 73cdebe

3 files changed

Lines changed: 90 additions & 1 deletion

File tree

routers/core/user.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
)
2222
from utils.core.images import (
2323
validate_and_process_image,
24+
read_upload_with_size_limit,
25+
reject_oversized_content_length,
2426
MAX_FILE_SIZE,
27+
MAX_AVATAR_UPLOAD_BYTES,
2528
MIN_DIMENSION,
2629
MAX_DIMENSION,
2730
ALLOWED_CONTENT_TYPES,
@@ -131,7 +134,10 @@ async def update_profile(
131134
# Handle avatar update
132135
if avatar_changed:
133136
assert avatar_file is not None
134-
avatar_data = await avatar_file.read()
137+
reject_oversized_content_length(
138+
request.headers.get("content-length"), MAX_AVATAR_UPLOAD_BYTES
139+
)
140+
avatar_data = await read_upload_with_size_limit(avatar_file, MAX_FILE_SIZE)
135141
avatar_content_type = avatar_file.content_type
136142

137143
processed_image, content_type = validate_and_process_image(

tests/utils/test_images.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import pytest
22
from PIL import Image
33
import io
4+
import asyncio
5+
from concurrent.futures import ThreadPoolExecutor
6+
from unittest.mock import AsyncMock, MagicMock
47
from utils.core.images import (
58
validate_and_process_image,
9+
read_upload_with_size_limit,
10+
reject_oversized_content_length,
611
InvalidImageError,
712
MAX_FILE_SIZE,
13+
MAX_AVATAR_UPLOAD_BYTES,
814
MIN_DIMENSION,
915
MAX_DIMENSION,
1016
)
@@ -18,6 +24,12 @@ def create_test_image(width: int, height: int, format: str = "PNG") -> bytes:
1824
return output.getvalue()
1925

2026

27+
def _run_async(coro):
28+
"""Run a coroutine when pytest may already have an event loop active."""
29+
with ThreadPoolExecutor(max_workers=1) as executor:
30+
return executor.submit(asyncio.run, coro).result()
31+
32+
2133
def test_webp_dependencies_are_installed():
2234
"""Test that webp dependencies are installed"""
2335
assert ".webp" in Image.registered_extensions(), (
@@ -89,6 +101,41 @@ def test_file_too_large():
89101
assert "File too large" in str(exc_info.value.detail)
90102

91103

104+
def test_read_upload_with_size_limit_rejects_oversized_stream():
105+
upload = MagicMock()
106+
chunk_size = 64 * 1024
107+
upload.read = AsyncMock(
108+
side_effect=[b"x" * chunk_size, b"x" * (MAX_FILE_SIZE - chunk_size + 1), b""]
109+
)
110+
111+
with pytest.raises(InvalidImageError) as exc_info:
112+
_run_async(read_upload_with_size_limit(upload, MAX_FILE_SIZE))
113+
114+
assert "File too large" in str(exc_info.value.detail)
115+
assert upload.read.await_count == 2
116+
117+
118+
def test_read_upload_with_size_limit_accepts_valid_stream():
119+
upload = MagicMock()
120+
upload.read = AsyncMock(side_effect=[b"abc", b"def", b""])
121+
122+
data = _run_async(read_upload_with_size_limit(upload, MAX_FILE_SIZE))
123+
124+
assert data == b"abcdef"
125+
126+
127+
def test_reject_oversized_content_length():
128+
with pytest.raises(InvalidImageError):
129+
reject_oversized_content_length(
130+
str(MAX_AVATAR_UPLOAD_BYTES + 1), MAX_AVATAR_UPLOAD_BYTES
131+
)
132+
133+
134+
def test_reject_oversized_content_length_allows_missing_or_valid():
135+
reject_oversized_content_length(None, MAX_AVATAR_UPLOAD_BYTES)
136+
reject_oversized_content_length(str(MAX_FILE_SIZE), MAX_AVATAR_UPLOAD_BYTES)
137+
138+
92139
def test_corrupt_image_data():
93140
"""Test that corrupt image data is rejected"""
94141
corrupt_data = b"not an image"

utils/core/images.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
from PIL import Image
33
import io
44
from typing import Tuple
5+
from fastapi import UploadFile
56
from exceptions.http_exceptions import InvalidImageError
67

78

89
# --- Constants ---
910

1011

1112
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB in bytes
13+
# Multipart overhead for the profile form (fields + boundaries).
14+
MAX_AVATAR_UPLOAD_BYTES = MAX_FILE_SIZE + 64 * 1024
15+
READ_CHUNK_SIZE = 64 * 1024
1216
ALLOWED_CONTENT_TYPES = {"image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WEBP"}
1317
MIN_DIMENSION = 100
1418
MAX_DIMENSION = 2000
@@ -17,6 +21,38 @@
1721
# --- Functions ---
1822

1923

24+
def reject_oversized_content_length(content_length: str | None, max_bytes: int) -> None:
25+
"""Reject requests whose Content-Length exceeds the byte budget."""
26+
if not content_length:
27+
return
28+
try:
29+
declared_size = int(content_length)
30+
except ValueError:
31+
return
32+
if declared_size > max_bytes:
33+
raise InvalidImageError(message="File too large (max 2MB)")
34+
35+
36+
async def read_upload_with_size_limit(
37+
upload_file: UploadFile, max_bytes: int = MAX_FILE_SIZE
38+
) -> bytes:
39+
"""
40+
Read an upload in fixed-size chunks, aborting before buffering more than
41+
max_bytes into memory.
42+
"""
43+
chunks: list[bytes] = []
44+
total = 0
45+
while True:
46+
chunk = await upload_file.read(READ_CHUNK_SIZE)
47+
if not chunk:
48+
break
49+
total += len(chunk)
50+
if total > max_bytes:
51+
raise InvalidImageError(message="File too large (max 2MB)")
52+
chunks.append(chunk)
53+
return b"".join(chunks)
54+
55+
2056
def validate_and_process_image(
2157
image_data: bytes, content_type: str | None
2258
) -> Tuple[bytes, str]:

0 commit comments

Comments
 (0)