Files
copilot-swe-agent[bot] ca27a0b687 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>
2026-02-22 14:41:04 +00:00

115 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)