feat(security): add request size limits to API endpoints

- Add RequestSizeLimitMiddleware that checks Content-Length header
  before request body is read: non-multipart requests capped at
  MAX_REQUEST_BODY_SIZE (default 1 MB), multipart uploads capped at
  MAX_UPLOAD_SIZE (default 1 GB). Returns HTTP 413 on violation.
- Register middleware in app/main.py
- Add max_request_body_size setting to app/config.py
- Fix ui_upload in files.py to check Content-Length early and read
  in 64 KB chunks (bounded memory usage), removing the post-write
  os.path.getsize check
- Document MAX_REQUEST_BODY_SIZE in .env.demo and ConfigurationGuide.md
- Mark SECURITY_AUDIT.md item #4 as resolved
- Add 9 tests in test_request_size_limit.py
- Update test_upload_file_too_large to use patch.object instead of
  the now-unused os.path.getsize mock

Closes #173

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-22 14:41:04 +00:00
parent c166bfa506
commit ca27a0b687
9 changed files with 872 additions and 176 deletions
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Request Size Limit Middleware for DocuElevate.
This middleware enforces configurable size limits on incoming HTTP request bodies
to prevent memory exhaustion and Denial-of-Service (DoS) attacks.
Two independent limits are enforced:
- ``MAX_REQUEST_BODY_SIZE``: applied to all non-multipart requests (JSON, form data, etc.).
Default: 1 MB. Configurable via the ``MAX_REQUEST_BODY_SIZE`` environment variable.
- ``MAX_UPLOAD_SIZE``: applied to multipart/form-data (file upload) requests.
Default: 1 GB. Configurable via the ``MAX_UPLOAD_SIZE`` environment variable.
When a request exceeds the applicable limit the middleware immediately returns
``HTTP 413 Request Entity Too Large`` without reading the full body, which keeps
memory usage bounded.
See SECURITY_AUDIT.md Code Security section for background.
"""
import logging
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""
Middleware that rejects requests whose body exceeds a configured size limit.
File-upload requests (``Content-Type: multipart/form-data``) are checked
against ``config.max_upload_size``; all other requests are checked against
``config.max_request_body_size``.
The check is performed on the ``Content-Length`` header before the body is
read, so oversized requests are rejected without buffering the payload into
memory. If the client omits the ``Content-Length`` header the request is
passed through to the normal handler (where endpoint-level checks still
apply for file uploads).
"""
def __init__(self, app, config):
"""
Initialize the middleware.
Args:
app: The ASGI application to wrap.
config: Application settings object with ``max_request_body_size``
and ``max_upload_size`` attributes.
"""
super().__init__(app)
self.max_body_size = config.max_request_body_size
self.max_upload_size = config.max_upload_size
logger.info(
f"Request size limit middleware enabled "
f"body limit: {self.max_body_size} bytes, "
f"upload limit: {self.max_upload_size} bytes"
)
async def dispatch(self, request: Request, call_next):
"""
Check the ``Content-Length`` header and reject oversized requests early.
Args:
request: Incoming HTTP request.
call_next: Next middleware or route handler.
Returns:
HTTP 413 response if the request is too large, otherwise the
downstream response.
"""
content_length_header = request.headers.get("content-length")
if content_length_header is not None:
try:
content_length = int(content_length_header)
except ValueError:
# Malformed header let downstream handle it
return await call_next(request)
content_type = request.headers.get("content-type", "")
is_multipart = "multipart/form-data" in content_type
if is_multipart:
limit = self.max_upload_size
limit_description = "file upload"
config_var = "MAX_UPLOAD_SIZE"
else:
limit = self.max_body_size
limit_description = "request body"
config_var = "MAX_REQUEST_BODY_SIZE"
if content_length > limit:
logger.warning(
f"Rejected oversized {limit_description}: "
f"{content_length} bytes > {limit} bytes limit "
f"(configure with {config_var})"
)
return JSONResponse(
status_code=413,
content={
"detail": (
f"Request body too large: {content_length} bytes "
f"(maximum allowed: {limit} bytes). "
f"Adjust the {config_var} environment variable to change this limit. "
f"See SECURITY_AUDIT.md for details."
)
},
)
return await call_next(request)