fix(api): close request-size gaps in body and history caps
Browse filesTwo 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>
- app/api.py +68 -15
- tests/test_api.py +63 -0
|
@@ -10,11 +10,12 @@ from typing import Any, AsyncIterator
|
|
| 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 |
|
| 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 |
-
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -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."""
|