Skip to content

Commit 1c761fd

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents a1981ae + 73cdebe commit 1c761fd

6 files changed

Lines changed: 101 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.1.4 (2026-07-02)
11+
12+
### Bug Fixes
13+
14+
- Honor X-Forwarded-For for rate limits behind trusted proxies
15+
([#221](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/221),
16+
[`aeac6e5`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/aeac6e511cec581fc0598bef807b18224c19e6f6))
17+
18+
1019
## v1.1.3 (2026-07-02)
1120

1221
### Bug Fixes

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.1.3"
3+
version = "1.1.4"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

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]:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)