Skip to content

Commit e50ef97

Browse files
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.
1 parent 523fc48 commit e50ef97

3 files changed

Lines changed: 83 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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import pytest
22
from PIL import Image
33
import io
4+
import asyncio
5+
from unittest.mock import AsyncMock, MagicMock
46
from utils.core.images import (
57
validate_and_process_image,
8+
read_upload_with_size_limit,
9+
reject_oversized_content_length,
610
InvalidImageError,
711
MAX_FILE_SIZE,
12+
MAX_AVATAR_UPLOAD_BYTES,
813
MIN_DIMENSION,
914
MAX_DIMENSION,
1015
)
@@ -89,6 +94,41 @@ def test_file_too_large():
8994
assert "File too large" in str(exc_info.value.detail)
9095

9196

97+
def test_read_upload_with_size_limit_rejects_oversized_stream():
98+
upload = MagicMock()
99+
chunk_size = 64 * 1024
100+
upload.read = AsyncMock(
101+
side_effect=[b"x" * chunk_size, b"x" * (MAX_FILE_SIZE - chunk_size + 1), b""]
102+
)
103+
104+
with pytest.raises(InvalidImageError) as exc_info:
105+
asyncio.run(read_upload_with_size_limit(upload, MAX_FILE_SIZE))
106+
107+
assert "File too large" in str(exc_info.value.detail)
108+
assert upload.read.await_count == 2
109+
110+
111+
def test_read_upload_with_size_limit_accepts_valid_stream():
112+
upload = MagicMock()
113+
upload.read = AsyncMock(side_effect=[b"abc", b"def", b""])
114+
115+
data = asyncio.run(read_upload_with_size_limit(upload, MAX_FILE_SIZE))
116+
117+
assert data == b"abcdef"
118+
119+
120+
def test_reject_oversized_content_length():
121+
with pytest.raises(InvalidImageError):
122+
reject_oversized_content_length(
123+
str(MAX_AVATAR_UPLOAD_BYTES + 1), MAX_AVATAR_UPLOAD_BYTES
124+
)
125+
126+
127+
def test_reject_oversized_content_length_allows_missing_or_valid():
128+
reject_oversized_content_length(None, MAX_AVATAR_UPLOAD_BYTES)
129+
reject_oversized_content_length(str(MAX_FILE_SIZE), MAX_AVATAR_UPLOAD_BYTES)
130+
131+
92132
def test_corrupt_image_data():
93133
"""Test that corrupt image data is rejected"""
94134
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)