Coverage for gco / services / request_size_middleware.py: 100.00%
52 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Shared request-body size enforcement for GCO FastAPI services."""
3from __future__ import annotations
5from collections import deque
7from starlette.responses import JSONResponse
8from starlette.types import ASGIApp, Message, Receive, Scope, Send
10# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
11# Generated at (UTC): 2026-09-01T14:42:56Z
12# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc
13# Flowchart(s) generated from this file:
14# * ``RequestSizeLimitMiddleware.__call__`` -> ``diagrams/code_diagrams/gco/services/request_size_middleware.RequestSizeLimitMiddleware___call__.html``
15# (PNG: ``diagrams/code_diagrams/gco/services/request_size_middleware.RequestSizeLimitMiddleware___call__.png``)
16# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
17# <pyflowchart-code-diagram> END
20DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576
23class RequestSizeLimitMiddleware:
24 """Reject request bodies larger than the configured byte limit.
26 ``Content-Length`` is only an early-rejection optimization. Every accepted
27 request is read from the ASGI receive channel and counted before it reaches
28 authentication or a route handler, so a missing, malformed, negative, or
29 deliberately under-reported header cannot bypass the limit. Buffered
30 messages are replayed unchanged to preserve the exact bytes used by HMAC
31 validation and downstream parsing.
32 """
34 def __init__(self, app: ASGIApp, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES) -> None:
35 if max_body_bytes < 0:
36 raise ValueError("max_body_bytes must be non-negative")
37 self.app = app
38 self.max_body_bytes = max_body_bytes
40 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
41 if scope["type"] != "http":
42 await self.app(scope, receive, send)
43 return
45 if self._declared_size_exceeds_limit(scope):
46 await self._too_large_response()(scope, receive, send)
47 return
49 buffered_messages: deque[Message] = deque()
50 received_bytes = 0
51 while True:
52 message = await receive()
53 message_type = message["type"]
55 if message_type == "http.request":
56 received_bytes += len(message.get("body", b""))
57 if received_bytes > self.max_body_bytes:
58 await self._too_large_response()(scope, receive, send)
59 return
60 buffered_messages.append(message)
61 if not message.get("more_body", False):
62 break
63 else:
64 buffered_messages.append(message)
65 if message_type == "http.disconnect":
66 break
68 async def replay_receive() -> Message:
69 if buffered_messages:
70 return buffered_messages.popleft()
71 return await receive()
73 await self.app(scope, replay_receive, send)
75 def _declared_size_exceeds_limit(self, scope: Scope) -> bool:
76 """Return true if any valid Content-Length value is already too large."""
77 for name, raw_value in scope.get("headers", []):
78 if name.lower() != b"content-length":
79 continue
80 try:
81 declared_size = int(raw_value.decode("ascii"))
82 except UnicodeDecodeError, ValueError:
83 continue
84 if declared_size > self.max_body_bytes:
85 return True
86 return False
88 def _too_large_response(self) -> JSONResponse:
89 return JSONResponse(
90 status_code=413,
91 content={"detail": f"Request body exceeds maximum size of {self.max_body_bytes} bytes"},
92 )