Skip to content

Commit 2d12187

Browse files
committed
feat: add trust_proxy_headers switch for auth rate-limit IP source
1 parent 0f6a059 commit 2d12187

6 files changed

Lines changed: 144 additions & 2 deletions

File tree

astrbot/core/config/default.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@
252252
"host": "0.0.0.0",
253253
"port": 6185,
254254
"disable_access_log": True,
255+
"trust_proxy_headers": False,
255256
"totp": {
256257
"enable": False,
257258
"secret": "",
@@ -2971,6 +2972,7 @@
29712972
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
29722973
},
29732974
"dashboard.ssl.enable": {"type": "bool"},
2975+
"dashboard.trust_proxy_headers": {"type": "bool"},
29742976
"dashboard.ssl.cert_file": {
29752977
"type": "string",
29762978
"condition": {"dashboard.ssl.enable": True},
@@ -4213,6 +4215,11 @@
42134215
"type": "bool",
42144216
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。",
42154217
},
4218+
"dashboard.trust_proxy_headers": {
4219+
"description": "信任代理请求头获取客户端 IP",
4220+
"type": "bool",
4221+
"hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。",
4222+
},
42164223
"dashboard.totp.enable": {
42174224
"description": "启用 WebUI TOTP 双因素认证",
42184225
"type": "bool",

astrbot/dashboard/server.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,11 @@ async def auth_middleware(self):
283283
os.environ.get("ASTRBOT_TEST_MODE") != "true"
284284
and request.path in _RATE_LIMITED_ENDPOINTS
285285
):
286-
limiter = _rate_limiters.get(request.path)
286+
client_ip = self._get_request_client_ip()
287+
limiter = _rate_limiters.get(client_ip)
287288
if limiter is None:
288289
limiter = _AuthRateLimiter(capacity=3, refill_rate=1.0)
289-
_rate_limiters[request.path] = limiter
290+
_rate_limiters[client_ip] = limiter
290291
if not await limiter.acquire():
291292
r = jsonify(
292293
Response()
@@ -345,6 +346,26 @@ async def auth_middleware(self):
345346
r.status_code = 401
346347
return r
347348

349+
def _get_request_client_ip(self) -> str:
350+
trust_proxy_headers = bool(
351+
self.config.get("dashboard", {}).get("trust_proxy_headers", False)
352+
)
353+
if trust_proxy_headers:
354+
forwarded_for = request.headers.get("X-Forwarded-For", "").strip()
355+
if forwarded_for:
356+
first_ip = forwarded_for.split(",", 1)[0].strip()
357+
if first_ip and first_ip.lower() != "unknown":
358+
return first_ip
359+
360+
real_ip = request.headers.get("X-Real-IP", "").strip()
361+
if real_ip and real_ip.lower() != "unknown":
362+
return real_ip
363+
364+
remote_addr = request.remote_addr
365+
if remote_addr:
366+
return str(remote_addr)
367+
return "unknown"
368+
348369
@staticmethod
349370
def _extract_dashboard_jwt() -> str | None:
350371
auth_header = request.headers.get("Authorization", "").strip()

dashboard/src/i18n/locales/en-US/features/config-metadata.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,10 @@
10881088
"hint": "When disabled, AstrBot will not upload anonymous usage statistics."
10891089
},
10901090
"dashboard": {
1091+
"trust_proxy_headers": {
1092+
"description": "Trust Proxy Headers for Client IP",
1093+
"hint": "When disabled, ignore X-Forwarded-For/X-Real-IP and use the connection address only."
1094+
},
10911095
"ssl": {
10921096
"enable": {
10931097
"description": "Enable WebUI HTTPS",

dashboard/src/i18n/locales/ru-RU/features/config-metadata.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,10 @@
10891089
"hint": "После отключения AstrBot не будет отправлять анонимные данные об использовании."
10901090
},
10911091
"dashboard": {
1092+
"trust_proxy_headers": {
1093+
"description": "Доверять прокси-заголовкам для IP клиента",
1094+
"hint": "Если выключено, X-Forwarded-For/X-Real-IP игнорируются и используется только адрес соединения."
1095+
},
10921096
"ssl": {
10931097
"enable": {
10941098
"description": "Включить HTTPS для WebUI",

dashboard/src/i18n/locales/zh-CN/features/config-metadata.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,10 @@
10901090
"hint": "禁用后,AstrBot 将不再上传匿名使用统计数据。"
10911091
},
10921092
"dashboard": {
1093+
"trust_proxy_headers": {
1094+
"description": "信任代理请求头获取客户端 IP",
1095+
"hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。"
1096+
},
10931097
"ssl": {
10941098
"enable": {
10951099
"description": "启用 WebUI HTTPS",

tests/test_dashboard.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from quart import Quart, jsonify
1919
from werkzeug.datastructures import FileStorage
2020

21+
import astrbot.dashboard.server as dashboard_server
2122
from astrbot.core import LogBroker
2223
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
2324
from astrbot.core.db.sqlite import SQLiteDatabase
@@ -361,6 +362,107 @@ async def test_auth_login_secure_cookie_override(
361362
assert "SameSite=Strict" in jwt_cookie_header
362363

363364

365+
@pytest.mark.asyncio
366+
async def test_auth_rate_limit_uses_client_ip_bucket_across_paths(
367+
app: Quart,
368+
core_lifecycle_td: AstrBotCoreLifecycle,
369+
monkeypatch: pytest.MonkeyPatch,
370+
):
371+
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
372+
dashboard_server._rate_limiters.clear()
373+
original_value = core_lifecycle_td.astrbot_config["dashboard"].get(
374+
"trust_proxy_headers", False
375+
)
376+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = True
377+
378+
try:
379+
test_client = app.test_client()
380+
headers = {"X-Forwarded-For": "198.51.100.10"}
381+
await test_client.post(
382+
"/api/auth/login",
383+
json={"username": "wrong", "password": "wrong"},
384+
headers=headers,
385+
)
386+
await test_client.post("/api/auth/totp/setup", json={}, headers=headers)
387+
388+
assert len(dashboard_server._rate_limiters) == 1
389+
assert "198.51.100.10" in dashboard_server._rate_limiters
390+
finally:
391+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = (
392+
original_value
393+
)
394+
395+
396+
@pytest.mark.asyncio
397+
async def test_auth_rate_limit_separates_different_client_ips(
398+
app: Quart,
399+
core_lifecycle_td: AstrBotCoreLifecycle,
400+
monkeypatch: pytest.MonkeyPatch,
401+
):
402+
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
403+
dashboard_server._rate_limiters.clear()
404+
original_value = core_lifecycle_td.astrbot_config["dashboard"].get(
405+
"trust_proxy_headers", False
406+
)
407+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = True
408+
409+
try:
410+
test_client = app.test_client()
411+
await test_client.post(
412+
"/api/auth/login",
413+
json={"username": "wrong", "password": "wrong"},
414+
headers={"X-Forwarded-For": "198.51.100.10"},
415+
)
416+
await test_client.post(
417+
"/api/auth/login",
418+
json={"username": "wrong", "password": "wrong"},
419+
headers={"X-Forwarded-For": "198.51.100.11"},
420+
)
421+
422+
assert len(dashboard_server._rate_limiters) == 2
423+
assert "198.51.100.10" in dashboard_server._rate_limiters
424+
assert "198.51.100.11" in dashboard_server._rate_limiters
425+
finally:
426+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = (
427+
original_value
428+
)
429+
430+
431+
@pytest.mark.asyncio
432+
async def test_auth_rate_limit_ignores_proxy_headers_by_default(
433+
app: Quart,
434+
core_lifecycle_td: AstrBotCoreLifecycle,
435+
monkeypatch: pytest.MonkeyPatch,
436+
):
437+
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
438+
dashboard_server._rate_limiters.clear()
439+
original_value = core_lifecycle_td.astrbot_config["dashboard"].get(
440+
"trust_proxy_headers", False
441+
)
442+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = False
443+
444+
try:
445+
test_client = app.test_client()
446+
await test_client.post(
447+
"/api/auth/login",
448+
json={"username": "wrong", "password": "wrong"},
449+
headers={"X-Forwarded-For": "198.51.100.20"},
450+
)
451+
await test_client.post(
452+
"/api/auth/login",
453+
json={"username": "wrong", "password": "wrong"},
454+
headers={"X-Forwarded-For": "198.51.100.21"},
455+
)
456+
457+
assert len(dashboard_server._rate_limiters) == 1
458+
assert "198.51.100.20" not in dashboard_server._rate_limiters
459+
assert "198.51.100.21" not in dashboard_server._rate_limiters
460+
finally:
461+
core_lifecycle_td.astrbot_config["dashboard"]["trust_proxy_headers"] = (
462+
original_value
463+
)
464+
465+
364466
@pytest.mark.asyncio
365467
async def test_auth_login_requires_totp_when_enabled_and_not_trusted(
366468
app: Quart,

0 commit comments

Comments
 (0)