"""ASGI middleware: reject requests whose body exceeds the configured size limit. Defence-in-depth: nginx already enforces ``client_max_body_size 500m`` at the transport layer. This middleware adds a second check at the ASGI layer so that requests that bypass nginx (local dev, direct container access) are also capped. Size limits (musehub#228): Push-scale endpoints (push/mpack-presign, push/unpack-mpack, repair-object): 500 MB — matches nginx. repair-object joins this tier because it carries arbitrary raw object bytes — the same unbounded-by-nature payload shape as a push, not a small metadata record. Repair-metadata endpoints (repair-commit, repair-snapshot): 100 MB. These carry a single commit's structured_delta / a snapshot's manifest — bounded by metadata size, not raw content, but can legitimately exceed the generic 10 MB API cap for a commit with a very large structured diff (observed: a 15.6 MB structured_delta on a real bulk-refactor commit triggered a false-positive 413 here before this tier existed). 100 MB gives ~6x headroom over that observed case without reaching push-scale. All other routes: 10 MB. Two-phase check: 1. Content-Length header — fast rejection before reading any body bytes. 2. Streaming body counter — rejects mid-stream when Content-Length is absent. On overflow a disconnect signal is sent to the handler and the response is replaced with 413 Request Entity Too Large. """ import re from starlette.types import ASGIApp, Message, Receive, Scope, Send # Push-scale endpoints — large payloads expected (mpack presign + unpack, and # repair-object which can replace any object ever legitimately pushed). _PUSH_SCALE_PATH_RE = re.compile( r"^/[^/]+/[^/]+/(push/(mpack-presign|unpack-mpack)|repair-object)$" ) # Repair-metadata endpoints — single-record payloads that can exceed the # generic API cap (structured_delta / manifest) without approaching push scale. _REPAIR_PATH_RE = re.compile(r"^/[^/]+/[^/]+/(repair-commit|repair-snapshot)$") PUSH_MAX_BYTES: int = 500 * 1024 * 1024 # 500 MB — matches nginx client_max_body_size REPAIR_MAX_BYTES: int = 100 * 1024 * 1024 # 100 MB — repair-commit, repair-snapshot API_MAX_BYTES: int = 10 * 1024 * 1024 # 10 MB — all other routes _413_START = { "type": "http.response.start", "status": 413, "headers": [(b"content-type", b"application/json")], } _413_BODY = { "type": "http.response.body", "body": b'{"detail":"Request body too large."}', "more_body": False, } class ContentSizeLimitMiddleware: """Pure-ASGI content-size limiter — no buffering of valid requests.""" def __init__(self, app: ASGIApp) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return path: str = scope.get("path", "") if _PUSH_SCALE_PATH_RE.match(path): limit = PUSH_MAX_BYTES elif _REPAIR_PATH_RE.match(path): limit = REPAIR_MAX_BYTES else: limit = API_MAX_BYTES # Phase 1: Content-Length header (fast path — no body reads needed). for name, value in scope.get("headers", ()): if name == b"content-length": try: if int(value) > limit: await send(_413_START) await send(_413_BODY) return except ValueError: pass break # Phase 2: streaming body counter (covers chunked-transfer / no Content-Length). total: int = 0 overflow: bool = False async def _limited_receive() -> Message: nonlocal total, overflow if overflow: return {"type": "http.disconnect"} message = await receive() if message.get("type") == "http.request": total += len(message.get("body", b"")) if total > limit: overflow = True return {"type": "http.disconnect"} return message response_started: bool = False async def _guarded_send(message: Message) -> None: nonlocal response_started if overflow and not response_started: if message.get("type") == "http.response.start": response_started = True await send(_413_START) await send(_413_BODY) return # Swallow body/trailer frames that follow an overridden start. return await send(message) await self.app(scope, _limited_receive, _guarded_send)