basyx commited on
Commit
bda3e6b
·
verified ·
1 Parent(s): b67d068

Make request body limit middleware return reliable 413 responses

Browse files
Files changed (1) hide show
  1. app/core/request_limits.py +55 -36
app/core/request_limits.py CHANGED
@@ -3,10 +3,16 @@ from __future__ import annotations
3
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
4
 
5
 
 
 
 
 
6
  class RequestBodyLimitMiddleware:
7
- """Enforce a hard byte ceiling on HTTP request bodies, including chunked bodies."""
8
 
9
  def __init__(self, app: ASGIApp, max_bytes: int) -> None:
 
 
10
  self.app = app
11
  self.max_bytes = max_bytes
12
 
@@ -17,19 +23,23 @@ class RequestBodyLimitMiddleware:
17
 
18
  headers = {key.lower(): value for key, value in scope.get("headers", [])}
19
  declared = headers.get(b"content-length")
20
- if declared:
21
  try:
22
- if int(declared) > self.max_bytes:
23
- await self._too_large(scope, receive, send)
24
- return
25
  except ValueError:
26
- # Invalid Content-Length is left to the ASGI server/parser; the
27
- # streamed byte counter below remains authoritative.
28
- pass
 
 
 
 
 
29
 
30
  received = 0
31
  exceeded = False
32
  response_started = False
 
33
 
34
  async def limited_receive() -> Message:
35
  nonlocal received, exceeded
@@ -38,43 +48,52 @@ class RequestBodyLimitMiddleware:
38
  received += len(message.get("body", b""))
39
  if received > self.max_bytes:
40
  exceeded = True
41
- # Do not pass an over-limit chunk into a multipart parser or
42
- # request.body(); it could otherwise spool/read the full body.
43
- return {"type": "http.disconnect"}
44
  return message
45
 
46
  async def guarded_send(message: Message) -> None:
47
- nonlocal response_started
48
- if message["type"] == "http.response.start":
49
- if exceeded and not response_started:
50
  response_started = True
51
- await send({
52
- "type": "http.response.start",
53
- "status": 413,
54
- "headers": [
55
- (b"content-type", b"application/json"),
56
- (b"cache-control", b"no-store"),
57
- ],
58
- })
59
- return
60
- response_started = True
61
- if exceeded:
62
- if message["type"] == "http.response.body" and not response_started:
63
- return
64
- if message["type"] == "http.response.body":
65
- await send({"type": "http.response.body", "body": b'{"detail":"Request body too large"}', "more_body": False})
66
  return
67
- await send(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  try:
70
  await self.app(scope, limited_receive, guarded_send)
71
- except Exception:
72
- if exceeded and not response_started:
73
- await self._too_large(scope, receive, send)
74
- return
75
- raise
 
 
 
 
 
76
 
77
- async def _too_large(self, scope: Scope, receive: Receive, send: Send) -> None:
78
  body = b'{"detail":"Request body too large"}'
79
  await send({
80
  "type": "http.response.start",
 
3
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
4
 
5
 
6
+ class _RequestBodyTooLarge(Exception):
7
+ """Internal signal used to stop an application from consuming an oversized body."""
8
+
9
+
10
  class RequestBodyLimitMiddleware:
11
+ """Enforce a request-body ceiling using Content-Length and streamed byte counts."""
12
 
13
  def __init__(self, app: ASGIApp, max_bytes: int) -> None:
14
+ if max_bytes < 1:
15
+ raise ValueError("max_bytes must be positive")
16
  self.app = app
17
  self.max_bytes = max_bytes
18
 
 
23
 
24
  headers = {key.lower(): value for key, value in scope.get("headers", [])}
25
  declared = headers.get(b"content-length")
26
+ if declared is not None:
27
  try:
28
+ content_length = int(declared)
 
 
29
  except ValueError:
30
+ await self._send_413(send)
31
+ return
32
+ if content_length < 0:
33
+ await self._send_413(send)
34
+ return
35
+ if content_length > self.max_bytes:
36
+ await self._send_413(send)
37
+ return
38
 
39
  received = 0
40
  exceeded = False
41
  response_started = False
42
+ response_body_sent = False
43
 
44
  async def limited_receive() -> Message:
45
  nonlocal received, exceeded
 
48
  received += len(message.get("body", b""))
49
  if received > self.max_bytes:
50
  exceeded = True
51
+ raise _RequestBodyTooLarge
 
 
52
  return message
53
 
54
  async def guarded_send(message: Message) -> None:
55
+ nonlocal response_started, response_body_sent
56
+ if not exceeded:
57
+ if message["type"] == "http.response.start":
58
  response_started = True
59
+ await send(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  return
61
+
62
+ # Replace any application response after overflow with one clean 413.
63
+ if message["type"] == "http.response.start" and not response_started:
64
+ response_started = True
65
+ body = b'{"detail":"Request body too large"}'
66
+ await send({
67
+ "type": "http.response.start",
68
+ "status": 413,
69
+ "headers": [
70
+ (b"content-type", b"application/json"),
71
+ (b"content-length", str(len(body)).encode("ascii")),
72
+ (b"cache-control", b"no-store"),
73
+ ],
74
+ })
75
+ elif message["type"] == "http.response.body" and not response_body_sent:
76
+ response_body_sent = True
77
+ await send({
78
+ "type": "http.response.body",
79
+ "body": b'{"detail":"Request body too large"}',
80
+ "more_body": False,
81
+ })
82
 
83
  try:
84
  await self.app(scope, limited_receive, guarded_send)
85
+ except _RequestBodyTooLarge:
86
+ if not response_started:
87
+ await self._send_413(send)
88
+ elif not response_body_sent:
89
+ response_body_sent = True
90
+ await send({
91
+ "type": "http.response.body",
92
+ "body": b'{"detail":"Request body too large"}',
93
+ "more_body": False,
94
+ })
95
 
96
+ async def _send_413(self, send: Send) -> None:
97
  body = b'{"detail":"Request body too large"}'
98
  await send({
99
  "type": "http.response.start",