omarsol Claude Fable 5 commited on
Commit
c0cba75
·
1 Parent(s): 25b8052

fix(api): close request-size gaps in body and history caps

Browse files

Two hardening gaps around the existing request bounds:

- The oversized-body middleware only checked Content-Length, so a
chunked request bypassed the cap entirely. Replace it with a pure
ASGI middleware that keeps the header fast-path 413 and counts the
bytes actually delivered on the receive channel.
- History turns extracted from the AI-SDK messages field were not held
to MAX_TURN_CHARS (only payload.history is, via its Field cap), so a
single message under MAX_MESSAGE_JSON_CHARS could carry a ~190k-char
turn into the model call. Truncate extracted turns to the same bound;
truncation rather than 422 because the oversized text already sits in
the client transcript and rejecting would wedge the conversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. app/api.py +68 -15
  2. tests/test_api.py +63 -0
app/api.py CHANGED
@@ -10,11 +10,12 @@ from typing import Any, AsyncIterator
10
  from uuid import uuid4
11
 
12
  import uvicorn
13
- from fastapi import FastAPI, HTTPException, Request
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.responses import JSONResponse, StreamingResponse
16
  from fastapi.staticfiles import StaticFiles
17
  from pydantic import BaseModel, Field, field_validator
 
18
 
19
  from .chat_service import (
20
  is_anthropic_model,
@@ -130,21 +131,60 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
130
  app = FastAPI(title="AI Tutor API", lifespan=lifespan)
131
 
132
 
133
- @app.middleware("http")
134
- async def reject_oversized_bodies(request: Request, call_next):
135
  """Refuse multi-megabyte bodies before parsing them. Pydantic caps bound
136
- the fields, but only after the whole body is read and JSON-decoded."""
137
- content_length = request.headers.get("content-length")
138
- try:
139
- too_large = content_length is not None and int(content_length) > MAX_BODY_BYTES
140
- except ValueError:
141
- too_large = False
142
- if too_large:
143
- return JSONResponse(
144
- {"detail": "Request body too large"},
145
- status_code=413,
146
- )
147
- return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  app.add_middleware(
@@ -205,6 +245,19 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
205
  # the turn in history would make the model see it twice.
206
  if history[-1].role == "user" and history[-1].content.strip() == query:
207
  history = history[:-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
  if not query:
210
  raise HTTPException(status_code=422, detail="query is required")
 
10
  from uuid import uuid4
11
 
12
  import uvicorn
13
+ from fastapi import FastAPI, HTTPException
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.responses import JSONResponse, StreamingResponse
16
  from fastapi.staticfiles import StaticFiles
17
  from pydantic import BaseModel, Field, field_validator
18
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
19
 
20
  from .chat_service import (
21
  is_anthropic_model,
 
131
  app = FastAPI(title="AI Tutor API", lifespan=lifespan)
132
 
133
 
134
+ class BodySizeLimitMiddleware:
 
135
  """Refuse multi-megabyte bodies before parsing them. Pydantic caps bound
136
+ the fields, but only after the whole body is read and JSON-decoded.
137
+
138
+ The Content-Length check rejects honest oversized requests before any
139
+ body byte is read; the receive wrapper counts the bytes actually
140
+ delivered, so a chunked request (no Content-Length) can't bypass the cap.
141
+ The mid-body HTTPException surfaces inside FastAPI's body read, which
142
+ re-raises middleware HTTPExceptions unchanged, so the client still gets
143
+ a clean 413 instead of a generic parse error.
144
+ """
145
+
146
+ def __init__(self, app: ASGIApp, max_bytes: int) -> None:
147
+ self.app = app
148
+ self.max_bytes = max_bytes
149
+
150
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
151
+ if scope["type"] != "http":
152
+ await self.app(scope, receive, send)
153
+ return
154
+
155
+ for name, value in scope.get("headers") or ():
156
+ if name != b"content-length":
157
+ continue
158
+ try:
159
+ declared = int(value)
160
+ except ValueError:
161
+ break
162
+ if declared > self.max_bytes:
163
+ response = JSONResponse(
164
+ {"detail": "Request body too large"},
165
+ status_code=413,
166
+ )
167
+ await response(scope, receive, send)
168
+ return
169
+ break
170
+
171
+ received = 0
172
+
173
+ async def limited_receive() -> Message:
174
+ nonlocal received
175
+ message = await receive()
176
+ if message["type"] == "http.request":
177
+ received += len(message.get("body") or b"")
178
+ if received > self.max_bytes:
179
+ raise HTTPException(
180
+ status_code=413, detail="Request body too large"
181
+ )
182
+ return message
183
+
184
+ await self.app(scope, limited_receive, send)
185
+
186
+
187
+ app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_BODY_BYTES)
188
 
189
 
190
  app.add_middleware(
 
245
  # the turn in history would make the model see it twice.
246
  if history[-1].role == "user" and history[-1].content.strip() == query:
247
  history = history[:-1]
248
+ # The ApiChatTurn Field cap only bounds payload.history; turns
249
+ # extracted from the AI-SDK messages list must obey the same per-turn
250
+ # bound (MAX_MESSAGE_JSON_CHARS is far larger because a message can
251
+ # carry several tool outputs). Truncate rather than 422: only the
252
+ # rendered text is capped (tool outputs don't survive extraction),
253
+ # and the oversized text already sits in the client's transcript, so
254
+ # rejecting it would wedge the whole conversation.
255
+ history = tuple(
256
+ turn
257
+ if len(turn.content) <= MAX_TURN_CHARS
258
+ else ChatTurn(role=turn.role, content=turn.content[:MAX_TURN_CHARS])
259
+ for turn in history
260
+ )
261
 
262
  if not query:
263
  raise HTTPException(status_code=422, detail="query is required")
tests/test_api.py CHANGED
@@ -631,6 +631,69 @@ class ApiTestCase(unittest.TestCase):
631
 
632
  self.assertEqual(response.status_code, 413)
633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  def test_chat_stream_emits_heartbeat_during_quiet_gap(self) -> None:
635
  """A silent stretch between events must put SSE comment frames on the
636
  wire so reverse proxies don't idle the connection out."""
 
631
 
632
  self.assertEqual(response.status_code, 413)
633
 
634
+ def test_chat_rejects_oversized_chunked_body(self) -> None:
635
+ """A chunked request carries no Content-Length, so the header check
636
+ alone would admit an arbitrarily large body; the byte counter on the
637
+ receive channel must still refuse it."""
638
+ from app.api import MAX_BODY_BYTES
639
+
640
+ body = json.dumps({"query": "x" * (MAX_BODY_BYTES + 1)}).encode()
641
+
642
+ def chunks() -> Iterator[bytes]:
643
+ for start in range(0, len(body), 64 * 1024):
644
+ yield body[start : start + 64 * 1024]
645
+
646
+ with TestClient(app) as client:
647
+ response = client.post(
648
+ "/api/chat",
649
+ content=chunks(),
650
+ headers={"content-type": "application/json"},
651
+ )
652
+
653
+ self.assertEqual(response.status_code, 413)
654
+
655
+ def test_history_turns_from_messages_are_capped(self) -> None:
656
+ """payload.history is Field-capped at MAX_TURN_CHARS, but turns
657
+ extracted from the AI-SDK messages list weren't: a message under
658
+ MAX_MESSAGE_JSON_CHARS could smuggle a ~190k-char history turn.
659
+ Extracted turn text is truncated (not 422d: the text already sits in
660
+ the client transcript, and tool outputs don't survive extraction)."""
661
+ from app.api import MAX_TURN_CHARS, ApiChatRequest, build_chat_request
662
+
663
+ oversized = "y" * (MAX_TURN_CHARS + 1000)
664
+ request = build_chat_request(
665
+ ApiChatRequest(
666
+ query="What is RAG?",
667
+ messages=[
668
+ {"role": "assistant", "content": oversized},
669
+ {
670
+ "role": "user",
671
+ "parts": [{"type": "text", "text": "What is RAG?"}],
672
+ },
673
+ ],
674
+ )
675
+ )
676
+
677
+ self.assertEqual(len(request.history), 1)
678
+ self.assertEqual(request.history[0].role, "assistant")
679
+ self.assertEqual(len(request.history[0].content), MAX_TURN_CHARS)
680
+
681
+ # The same cap holds when the query is extracted from the trailing
682
+ # message instead of being passed explicitly.
683
+ no_query = build_chat_request(
684
+ ApiChatRequest(
685
+ messages=[
686
+ {"role": "assistant", "content": oversized},
687
+ {
688
+ "role": "user",
689
+ "parts": [{"type": "text", "text": "What is RAG?"}],
690
+ },
691
+ ]
692
+ )
693
+ )
694
+ self.assertEqual(no_query.query, "What is RAG?")
695
+ self.assertEqual(len(no_query.history[0].content), MAX_TURN_CHARS)
696
+
697
  def test_chat_stream_emits_heartbeat_during_quiet_gap(self) -> None:
698
  """A silent stretch between events must put SSE comment frames on the
699
  wire so reverse proxies don't idle the connection out."""