From 2de130502bda1a07f923b448478cafe80cfb59e1 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Fri, 19 Jun 2026 04:41:05 -0700 Subject: [PATCH 1/2] security: limit request body size to 1MB in FastAPI and Nginx --- quantara/frontend/quantara.conf | 1 + quantara/frontend/quantara_dev.conf | 1 + quantara/web_app/api/main.py | 2 + quantara/web_app/api/middleware.py | 69 +++++++++++++++++++ .../web_app/tests/test_body_size_limit.py | 32 +++++++++ 5 files changed, 105 insertions(+) create mode 100644 quantara/web_app/api/middleware.py create mode 100644 quantara/web_app/tests/test_body_size_limit.py diff --git a/quantara/frontend/quantara.conf b/quantara/frontend/quantara.conf index 23a142eb0..71cc2c089 100644 --- a/quantara/frontend/quantara.conf +++ b/quantara/frontend/quantara.conf @@ -1,5 +1,6 @@ server { listen 80; + client_max_body_size 1M; server_name localhost; diff --git a/quantara/frontend/quantara_dev.conf b/quantara/frontend/quantara_dev.conf index be44efeb3..13a45951d 100644 --- a/quantara/frontend/quantara_dev.conf +++ b/quantara/frontend/quantara_dev.conf @@ -5,6 +5,7 @@ upstream backend_app { server { listen 443 ssl; # HTTPS server_name quantara.xyz; # your domain + client_max_body_size 1M; # SSL Certificates ssl_certificate quantara.xyz.chain.crt; # Bundle file diff --git a/quantara/web_app/api/main.py b/quantara/web_app/api/main.py index 36ef95583..55cb7763b 100644 --- a/quantara/web_app/api/main.py +++ b/quantara/web_app/api/main.py @@ -32,6 +32,7 @@ from web_app.api.referal import router as referal_router from web_app.api.wallet_auth import router as auth_router from web_app.config_validator import assert_valid_config +from web_app.api.middleware import MaxBodySizeMiddleware from web_app.db.database import init_db from web_app.db.database import init_db, get_database @@ -138,6 +139,7 @@ async def global_exception_handler(request: Request, exc: Exception): ) # Rate limiting middleware -- must be added after CORS/session so it wraps the # full middleware stack and can reject requests before they reach routers. +app.add_middleware(MaxBodySizeMiddleware, max_body_size=1024*1024) app.add_middleware(SlowAPIMiddleware) diff --git a/quantara/web_app/api/middleware.py b/quantara/web_app/api/middleware.py new file mode 100644 index 000000000..02ed5f996 --- /dev/null +++ b/quantara/web_app/api/middleware.py @@ -0,0 +1,69 @@ +import json + +class MaxBodySizeMiddleware: + """ASGI middleware to enforce a maximum request body size. + + Returns a 413 Payload Too Large response if the client request body exceeds + the configured maximum size (default 1MB). + """ + + def __init__(self, app, max_body_size: int = 1024 * 1024): + self.app = app + self.max_body_size = max_body_size + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # Check content-length header if present + content_length = 0 + for name, value in scope.get("headers", []): + if name.lower() == b"content-length": + try: + content_length = int(value) + except ValueError: + pass + break + + if content_length > self.max_body_size: + await self._send_413(send) + return + + # Wrap receive to dynamically count body bytes read + total_received = 0 + response_sent = False + + async def custom_receive(): + nonlocal total_received, response_sent + message = await receive() + if message["type"] == "http.request": + body_chunk = message.get("body", b"") + total_received += len(body_chunk) + if total_received > self.max_body_size: + if not response_sent: + await self._send_413(send) + response_sent = True + return {"type": "http.disconnect"} + return message + + try: + await self.app(scope, custom_receive, send) + except Exception: + if response_sent: + return + raise + + async def _send_413(self, send): + await send({ + "type": "http.response.start", + "status": 413, + "headers": [ + (b"content-type", b"application/json"), + ] + }) + await send({ + "type": "http.body", + "body": json.dumps({"detail": "Request payload too large"}).encode("utf-8"), + "more_body": False + }) diff --git a/quantara/web_app/tests/test_body_size_limit.py b/quantara/web_app/tests/test_body_size_limit.py new file mode 100644 index 000000000..170965319 --- /dev/null +++ b/quantara/web_app/tests/test_body_size_limit.py @@ -0,0 +1,32 @@ +import pytest +from httpx import AsyncClient +from web_app.api.main import app + +@pytest.mark.asyncio +async def test_body_size_limit_exceeds_1mb(): + # Generate a payload slightly larger than 1MB (1,048,577 bytes) + payload = b"a" * (1024 * 1024 + 1) + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.post("/health", content=payload) + assert response.status_code == 413 + +@pytest.mark.asyncio +async def test_body_size_limit_within_1mb(): + # Generate a payload within 1MB + payload = b"a" * 100 + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.post("/health", content=payload) + # The response code should not be 413. Since it is /health and doesn't support POST, + # it might return 405 Method Not Allowed or similar, but NOT 413. + assert response.status_code != 413 + +@pytest.mark.asyncio +async def test_body_size_limit_chunked_exceeds_1mb(): + # Stream payload chunked, total exceeding 1MB + async def chunk_generator(): + yield b"a" * (512 * 1024) + yield b"a" * (512 * 1024 + 1) + + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.post("/health", content=chunk_generator()) + assert response.status_code == 413 From 352a04857cae0df25825974a93fef4f183ce1c16 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Fri, 19 Jun 2026 05:03:09 -0700 Subject: [PATCH 2/2] fix(middleware): correct ASGI response body event type and place middleware outermost --- quantara/web_app/api/main.py | 2 +- quantara/web_app/api/middleware.py | 2 +- quantara/web_app/tests/test_body_size_limit.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/quantara/web_app/api/main.py b/quantara/web_app/api/main.py index 55cb7763b..3ca0a9a90 100644 --- a/quantara/web_app/api/main.py +++ b/quantara/web_app/api/main.py @@ -139,8 +139,8 @@ async def global_exception_handler(request: Request, exc: Exception): ) # Rate limiting middleware -- must be added after CORS/session so it wraps the # full middleware stack and can reject requests before they reach routers. -app.add_middleware(MaxBodySizeMiddleware, max_body_size=1024*1024) app.add_middleware(SlowAPIMiddleware) +app.add_middleware(MaxBodySizeMiddleware, max_body_size=1024*1024) @app.get("/health", tags=["Health"], summary="Health check endpoint") diff --git a/quantara/web_app/api/middleware.py b/quantara/web_app/api/middleware.py index 02ed5f996..3bdde2ab3 100644 --- a/quantara/web_app/api/middleware.py +++ b/quantara/web_app/api/middleware.py @@ -63,7 +63,7 @@ async def _send_413(self, send): ] }) await send({ - "type": "http.body", + "type": "http.response.body", "body": json.dumps({"detail": "Request payload too large"}).encode("utf-8"), "more_body": False }) diff --git a/quantara/web_app/tests/test_body_size_limit.py b/quantara/web_app/tests/test_body_size_limit.py index 170965319..80b6ba75c 100644 --- a/quantara/web_app/tests/test_body_size_limit.py +++ b/quantara/web_app/tests/test_body_size_limit.py @@ -7,7 +7,7 @@ async def test_body_size_limit_exceeds_1mb(): # Generate a payload slightly larger than 1MB (1,048,577 bytes) payload = b"a" * (1024 * 1024 + 1) async with AsyncClient(app=app, base_url="http://testserver") as client: - response = await client.post("/health", content=payload) + response = await client.post("/api/vault/deposit", content=payload) assert response.status_code == 413 @pytest.mark.asyncio @@ -15,9 +15,9 @@ async def test_body_size_limit_within_1mb(): # Generate a payload within 1MB payload = b"a" * 100 async with AsyncClient(app=app, base_url="http://testserver") as client: - response = await client.post("/health", content=payload) - # The response code should not be 413. Since it is /health and doesn't support POST, - # it might return 405 Method Not Allowed or similar, but NOT 413. + response = await client.post("/api/vault/deposit", content=payload) + # The response code should not be 413. Since it is /api/vault/deposit and doesn't + # have correct headers/payload, it might return 400, 401, or 422, but NOT 413. assert response.status_code != 413 @pytest.mark.asyncio @@ -28,5 +28,5 @@ async def chunk_generator(): yield b"a" * (512 * 1024 + 1) async with AsyncClient(app=app, base_url="http://testserver") as client: - response = await client.post("/health", content=chunk_generator()) + response = await client.post("/api/vault/deposit", content=chunk_generator()) assert response.status_code == 413