gabriel / musehub public
content_size.py python
95 lines 3.5 KB
Raw
sha256:20b27796ed512236119feef48b4577a8fd9ded05a15267dd4e6da23604ad1f1a docs: update Suggested Ownership Split table to reflect ful… Sonnet 5 28 days ago
1 """ASGI middleware: reject requests whose body exceeds the configured size limit.
2
3 Defence-in-depth: nginx already enforces ``client_max_body_size 500m`` at the
4 transport layer. This middleware adds a second check at the ASGI layer so that
5 requests that bypass nginx (local dev, direct container access) are also capped.
6
7 Size limits:
8 Push endpoints (/{owner}/{slug}/push/mpack-presign|push/unpack-mpack): 500 MB — matches nginx
9 All other routes : 10 MB
10
11 Two-phase check:
12 1. Content-Length header — fast rejection before reading any body bytes.
13 2. Streaming body counter — rejects mid-stream when Content-Length is absent.
14 On overflow a disconnect signal is sent to the handler and the response is
15 replaced with 413 Request Entity Too Large.
16 """
17
18 import re
19
20 from starlette.types import ASGIApp, Message, Receive, Scope, Send
21
22 # Push endpoints — large payloads expected (mpack presign + unpack).
23 _PUSH_PATH_RE = re.compile(r"^/[^/]+/[^/]+/push/(mpack-presign|unpack-mpack)$")
24
25 PUSH_MAX_BYTES: int = 500 * 1024 * 1024 # 500 MB — matches nginx client_max_body_size
26 API_MAX_BYTES: int = 10 * 1024 * 1024 # 10 MB — all other routes
27
28 _413_START = {
29 "type": "http.response.start",
30 "status": 413,
31 "headers": [(b"content-type", b"application/json")],
32 }
33 _413_BODY = {
34 "type": "http.response.body",
35 "body": b'{"detail":"Request body too large."}',
36 "more_body": False,
37 }
38
39 class ContentSizeLimitMiddleware:
40 """Pure-ASGI content-size limiter — no buffering of valid requests."""
41
42 def __init__(self, app: ASGIApp) -> None:
43 self.app = app
44
45 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
46 if scope["type"] != "http":
47 await self.app(scope, receive, send)
48 return
49
50 path: str = scope.get("path", "")
51 limit = PUSH_MAX_BYTES if _PUSH_PATH_RE.match(path) else API_MAX_BYTES
52
53 # Phase 1: Content-Length header (fast path — no body reads needed).
54 for name, value in scope.get("headers", ()):
55 if name == b"content-length":
56 try:
57 if int(value) > limit:
58 await send(_413_START)
59 await send(_413_BODY)
60 return
61 except ValueError:
62 pass
63 break
64
65 # Phase 2: streaming body counter (covers chunked-transfer / no Content-Length).
66 total: int = 0
67 overflow: bool = False
68
69 async def _limited_receive() -> Message:
70 nonlocal total, overflow
71 if overflow:
72 return {"type": "http.disconnect"}
73 message = await receive()
74 if message.get("type") == "http.request":
75 total += len(message.get("body", b""))
76 if total > limit:
77 overflow = True
78 return {"type": "http.disconnect"}
79 return message
80
81 response_started: bool = False
82
83 async def _guarded_send(message: Message) -> None:
84 nonlocal response_started
85 if overflow and not response_started:
86 if message.get("type") == "http.response.start":
87 response_started = True
88 await send(_413_START)
89 await send(_413_BODY)
90 return
91 # Swallow body/trailer frames that follow an overridden start.
92 return
93 await send(message)
94
95 await self.app(scope, _limited_receive, _guarded_send)
File History 3 commits
sha256:20b27796ed512236119feef48b4577a8fd9ded05a15267dd4e6da23604ad1f1a docs: update Suggested Ownership Split table to reflect ful… Sonnet 5 28 days ago
sha256:8ea2f75226d5834770ccaddbaa6efda1cc337446fd481bb385de8bec4555ae79 docs: Section 9 CI pipeline — confirm job queue is idempote… Sonnet 5 28 days ago
sha256:80e1a60a39562f6e616aaafbb27487f03abbec7d8ffc6164302b7a0c0bfc63ee docs: check off Section 0 items verified in inventory doc, … Sonnet 5 28 days ago