Skip to content

Commit 9fbfcaf

Browse files
fix: enforce streamed request body limits
1 parent d55aa3c commit 9fbfcaf

2 files changed

Lines changed: 67 additions & 3 deletions

File tree

scripts/bazi_engine/api.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
}
5454
_MAX_AI_STREAMS = int(os.getenv("BAZI_MAX_AI_STREAMS", "3"))
5555
_AI_STREAM_SLOTS = threading.BoundedSemaphore(max(1, _MAX_AI_STREAMS))
56+
_ALLOW_LEGACY_CHART_GET = os.getenv("BAZI_ALLOW_LEGACY_CHART_GET", "1").lower() in ("1", "true", "yes")
5657
_FUSION_STREAM_TOTAL_TIMEOUT = float(os.getenv("BAZI_FUSION_STREAM_TOTAL_TIMEOUT", "150"))
5758
_FUSION_STREAM_IDLE_TIMEOUT = float(os.getenv("BAZI_FUSION_STREAM_IDLE_TIMEOUT", "45"))
5859
_STREAM_HEARTBEAT_INTERVAL = float(os.getenv("BAZI_STREAM_HEARTBEAT_INTERVAL", "15"))
@@ -63,6 +64,10 @@
6364
]
6465

6566

67+
class RequestBodyTooLargeError(Exception):
68+
"""Raised while consuming a chunked request body over the configured limit."""
69+
70+
6671
class ChartStreamRequest(BaseModel):
6772
name: str = Field(default="", max_length=40)
6873
gender: Literal["男", "女"]
@@ -114,10 +119,31 @@ async def reject_large_request_bodies(request: Request, call_next):
114119
if request.method in {"POST", "PUT", "PATCH"}:
115120
content_length = request.headers.get("content-length")
116121
max_bytes = _LARGE_REQUEST_BODY_LIMITS.get(request.url.path, _MAX_REQUEST_BODY_BYTES)
117-
if content_length and int(content_length) > max_bytes:
118-
response = JSONResponse({"error": "请求体过大"}, status_code=413)
122+
if content_length:
123+
try:
124+
if int(content_length) > max_bytes:
125+
response = JSONResponse({"error": "请求体过大"}, status_code=413)
126+
except ValueError:
127+
response = JSONResponse({"error": "无效的请求体长度"}, status_code=400)
128+
if response is None:
129+
receive = request._receive
130+
received_bytes = 0
131+
132+
async def limited_receive():
133+
nonlocal received_bytes
134+
message = await receive()
135+
if message["type"] == "http.request":
136+
received_bytes += len(message.get("body", b""))
137+
if received_bytes > max_bytes:
138+
raise RequestBodyTooLargeError
139+
return message
140+
141+
request._receive = limited_receive
119142
if response is None:
120-
response = await call_next(request)
143+
try:
144+
response = await call_next(request)
145+
except RequestBodyTooLargeError:
146+
response = JSONResponse({"error": "请求体过大"}, status_code=413)
121147
response.headers.setdefault("X-Content-Type-Options", "nosniff")
122148
response.headers.setdefault("X-Frame-Options", "DENY")
123149
response.headers.setdefault("Referrer-Policy", "no-referrer")
@@ -176,6 +202,8 @@ def chart_api(
176202
hour_confirmed: bool = Query(False, description="出生时辰是否经用户确认"),
177203
practical: bool = Query(False, description="实用模式:仅返回白话解读,不包含技术推导"),
178204
):
205+
if not _ALLOW_LEGACY_CHART_GET:
206+
return JSONResponse({"error": "该入口已停用,请使用 POST /api/chart/stream"}, status_code=410)
179207
ln_range = None
180208
if liunian_from and liunian_to:
181209
ln_range = (liunian_from, liunian_to)
@@ -527,6 +555,9 @@ async def chart_stream(
527555
data: {"phase":"done"} — 全流程结束
528556
"""
529557

558+
if not _ALLOW_LEGACY_CHART_GET:
559+
return JSONResponse({"error": "该入口已停用,请使用 POST /api/chart/stream"}, status_code=410)
560+
logger.info("legacy chart stream GET used")
530561
payload = ChartStreamRequest(
531562
name=name,
532563
gender=gender,

scripts/tests/test_api.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import pytest
1414
from fastapi.testclient import TestClient
15+
from starlette.requests import Request
1516

1617
import bazi_engine._chart_context as chart_context
1718
from tests._chat_fixtures import chart_data_with_current_dayun
@@ -55,6 +56,28 @@ def test_api_sets_baseline_security_headers():
5556
assert response.headers["referrer-policy"] == "no-referrer"
5657

5758

59+
def test_request_body_limit_applies_to_chunked_body(monkeypatch):
60+
import bazi_engine.api as api_module
61+
62+
monkeypatch.setattr(api_module, "_MAX_REQUEST_BODY_BYTES", 4)
63+
chunks = iter((
64+
{"type": "http.request", "body": b"abc", "more_body": True},
65+
{"type": "http.request", "body": b"de", "more_body": False},
66+
))
67+
68+
async def receive():
69+
return next(chunks)
70+
71+
async def call_next(request):
72+
await request.body()
73+
return api_module.JSONResponse({"ok": True})
74+
75+
request = Request({"type": "http", "method": "POST", "path": "/api/feedback", "headers": []}, receive)
76+
response = asyncio.run(api_module.reject_large_request_bodies(request, call_next))
77+
78+
assert response.status_code == 413
79+
80+
5881
def test_chart_stream_returns_rules_and_done_events(monkeypatch):
5982
monkeypatch.setenv("BAZI_LLM_REVIEW", "0")
6083
monkeypatch.setenv("BAZI_AI_ENABLED", "0")
@@ -83,6 +106,16 @@ def test_chart_stream_returns_rules_and_done_events(monkeypatch):
83106
assert "data: [DONE]" in body
84107

85108

109+
def test_legacy_chart_get_can_be_disabled(monkeypatch):
110+
import bazi_engine.api as api_module
111+
from bazi_engine.api import app
112+
113+
monkeypatch.setattr(api_module, "_ALLOW_LEGACY_CHART_GET", False)
114+
params = {"gender": "男", "year": 2007, "month": 8, "day": 26, "hour": 20}
115+
assert TestClient(app).get("/api/chart", params=params).status_code == 410
116+
assert TestClient(app).get("/api/chart/stream", params=params).status_code == 410
117+
118+
86119
def test_chart_stream_post_returns_rules_and_done_events(monkeypatch):
87120
monkeypatch.setenv("BAZI_LLM_REVIEW", "0")
88121
monkeypatch.setenv("BAZI_AI_ENABLED", "0")

0 commit comments

Comments
 (0)