gabriel / musehub public
content_size.py python
95 lines 3.5 KB
Raw
sha256:2edd1943a6367c0da5d64db5b9dd3ac2cc61eebb3a7eda94894f7d4767d146f3 docs(#139): annotate unused DAG/commit-graph CSS instead of… Sonnet 5 minor ⚠ breaking 57 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 1 commit
sha256:2edd1943a6367c0da5d64db5b9dd3ac2cc61eebb3a7eda94894f7d4767d146f3 docs(#139): annotate unused DAG/commit-graph CSS instead of… Sonnet 5 minor 57 days ago