Skip to content

Commit 20eea25

Browse files
authored
Merge branch 'ZhuLinsen:main' into main
2 parents e919fd5 + 1d78793 commit 20eea25

7 files changed

Lines changed: 111 additions & 5 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ WEBUI_HOST=127.0.0.1
415415
WEBUI_PORT=8000
416416
# 启动 Web 服务前是否自动构建前端(npm install && npm run build,默认 true)
417417
WEBUI_AUTO_BUILD=true
418-
# 反向代理下信任 X-Forwarded-For 获取真实 IP(Nginx/Cloudflare 前置时设为 true,直连公网时保持 false 防伪造
418+
# 单层可信反向代理(如 Nginx → App)下信任 X-Forwarded-For 获取真实 IP,取最右值用于登录限流;多级代理/CDN 场景限流 key 可能退化为边缘代理 IP,需额外评估;直连公网时保持 false 防伪造
419419
# TRUST_X_FORWARDED_FOR=false
420420

421421
# ===================================

.github/workflows/daily_analysis.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ jobs:
8181
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}
8282
OPENAI_MODEL: ${{ vars.OPENAI_MODEL || secrets.OPENAI_MODEL }}
8383

84+
# DeepSeek
85+
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
86+
8487
# Anthropic / Claude
8588
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
8689
ANTHROPIC_MODEL: ${{ vars.ANTHROPIC_MODEL || secrets.ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022' }}
@@ -227,6 +230,7 @@ jobs:
227230
echo "【AI 配置】"
228231
echo " LiteLLM: $([ -n "$LITELLM_CONFIG" ] || [ -n "$LITELLM_API_KEY" ] || [ -n "$LITELLM_MODEL" ] && echo '✅ 已配置' || echo '❌ 未配置')"
229232
echo " Gemini API Key: $([ -n "$GEMINI_API_KEY" ] && echo '✅ 已配置' || echo '❌ 未配置')"
233+
echo " DeepSeek Key: $([ -n "$DEEPSEEK_API_KEY" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
230234
echo " AIHubMix Key: $([ -n "$AIHUBMIX_KEY" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
231235
echo " OpenAI API Key: $([ -n "$OPENAI_API_KEY" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
232236
echo " Anthropic Key: $([ -n "$ANTHROPIC_API_KEY" ] && echo '✅ 已配置' || echo '⚪ 未配置')"

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1111

1212
### 修复
1313

14+
- 🔒 **认证限流 X-Forwarded-For 取值修复(CWE-345)**#841 / #842)— `get_client_ip()` 从取 `X-Forwarded-For` 最左值改为最右值,防止攻击者通过伪造首部旋转限流桶绕过暴力破解保护;仅影响 `TRUST_X_FORWARDED_FOR=true` 且单层可信反向代理的部署场景,多级代理环境需按部署文档评估配置。
1415
- 📦 **恢复 LiteLLM 官方 PyPI 安装并锁定安全上限**`requirements.txt` 重新使用 `pip install litellm` 的官方 PyPI 安装路径,并在保留历史最低要求 `>=1.80.10` 的同时增加 `<1.82.7` 的安全上限,避免误装已被移除的 `1.82.7` / `1.82.8` 风险版本;Windows 桌面打包脚本也同步回退到标准 `pip install -r requirements.txt` 链路,减少特殊下载分支带来的维护成本。
1516
- 📨 **Telegram Markdown 解析失败回退纯文本**(fixes #850)— `src/notification_sender/telegram_sender.py` 现在会在 Telegram 返回 `HTTP 400` 且包含 `can't parse entities` / Markdown 解析错误时,自动去掉 `parse_mode` 后重试纯文本发送,避免 `*ST` 等正文内容直接导致整条通知失败。
1617
- 🔢 **A 股同码实时行情保留交易所提示**(fixes #852)— `DataFetcherManager``TushareFetcher` 现在会保留 `SZ000001` / `000001.SZ` 这类显式沪深提示,旧版 Tushare 实时行情降级分支不再把深市 `000001` 误判成 `sh000001` 上证指数。

docs/deploy-webui-cloud.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ sudo systemctl reload nginx
233233
配置成功后,直接用 `http://your-domain.com` 访问即可,不需要带端口号。
234234

235235
> **使用 Nginx 后的注意事项**
236-
> - 如果你开启了 Web 登录认证(`ADMIN_AUTH_ENABLED=true`),建议在 `.env` 中把 `TRUST_X_FORWARDED_FOR=true` 一并打开,否则系统可能无法正确识别真实 IP。
236+
> - 如果你开启了 Web 登录认证(`ADMIN_AUTH_ENABLED=true`),建议在 `.env` 中把 `TRUST_X_FORWARDED_FOR=true` 一并打开,否则系统可能无法正确识别真实 IP。该选项适用于**单层可信反向代理**(Nginx → App)部署;如果使用多级代理或 CDN(CDN → Nginx → App),登录限流的 key 可能退化为边缘代理 IP 而非真实客户端 IP,需根据实际拓扑评估。
237237
> - 如需 HTTPS,可以用 [Certbot](https://certbot.eff.org/) 自动申请免费的 Let's Encrypt 证书。
238238
239239
---

docs/full-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ daily_stock_analysis/
291291
|--------|------|--------|
292292
| `STOCK_LIST` | 自选股代码(逗号分隔) | - |
293293
| `ADMIN_AUTH_ENABLED` | Web 登录:设为 `true` 启用密码保护;首次访问在网页设置初始密码,可在「系统设置 > 修改密码」修改;忘记密码执行 `python -m src.auth reset_password` | `false` |
294-
| `TRUST_X_FORWARDED_FOR` | 反向代理部署时设为 `true` `X-Forwarded-For` 获取真实 IP(限流等);直连公网时保持 `false` 防伪造 | `false` |
294+
| `TRUST_X_FORWARDED_FOR` | 单层可信反向代理部署时设为 `true` `X-Forwarded-For` 最右值作为真实客户端 IP(用于登录限流等);直连公网时保持 `false` 防伪造。多级代理/CDN 场景下限流 key 可能退化为边缘代理 IP,需额外评估 | `false` |
295295
| `MAX_WORKERS` | 并发线程数 | `3` |
296296
| `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` |
297297
| `MARKET_REVIEW_REGION` | 大盘复盘市场区域:cn(A股)、us(美股)、both(两者),us 适合仅关注美股的用户 | `cn` |

src/auth.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,17 @@ def verify_session(value: str) -> bool:
368368

369369

370370
def get_client_ip(request) -> str:
371-
"""Get client IP, respecting TRUST_X_FORWARDED_FOR."""
371+
"""Get client IP, respecting TRUST_X_FORWARDED_FOR.
372+
373+
When behind a single trusted reverse proxy, the proxy appends the real
374+
client IP as the rightmost entry in X-Forwarded-For. We use [-1] instead
375+
of [0] so that an attacker cannot spoof an arbitrary leftmost value to
376+
rotate rate-limit buckets and bypass brute-force protection.
377+
"""
372378
if os.getenv("TRUST_X_FORWARDED_FOR", "false").lower() == "true":
373379
forwarded = request.headers.get("X-Forwarded-For")
374380
if forwarded:
375-
return forwarded.split(",")[0].strip()
381+
return forwarded.split(",")[-1].strip()
376382
if request.client:
377383
return request.client.host or "127.0.0.1"
378384
return "127.0.0.1"

tests/test_cwe345_xff_bypass.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# -*- coding: utf-8 -*-
2+
"""Tests for CWE-345 fix: X-Forwarded-For IP spoofing prevention in get_client_ip."""
3+
4+
import os
5+
import unittest
6+
from types import SimpleNamespace
7+
from unittest.mock import patch
8+
9+
from src.auth import get_client_ip
10+
11+
12+
def _make_request(xff_value=None, client_host=None):
13+
"""Build a minimal request-like object."""
14+
headers = {}
15+
if xff_value is not None:
16+
headers["X-Forwarded-For"] = xff_value
17+
client = SimpleNamespace(host=client_host) if client_host else None
18+
return SimpleNamespace(headers=headers, client=client)
19+
20+
21+
class TestGetClientIpXffFix(unittest.TestCase):
22+
"""Verify get_client_ip uses rightmost XFF entry (proxy-appended)."""
23+
24+
# --- TRUST_X_FORWARDED_FOR enabled ---
25+
26+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
27+
def test_single_ip_returns_that_ip(self):
28+
"""Single-entry XFF should return that entry."""
29+
req = _make_request(xff_value="1.2.3.4")
30+
self.assertEqual(get_client_ip(req), "1.2.3.4")
31+
32+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
33+
def test_multiple_ips_returns_rightmost(self):
34+
"""Rightmost entry is the one appended by the trusted proxy."""
35+
req = _make_request(xff_value="spoofed.ip, 10.0.0.1, 192.168.1.1")
36+
self.assertEqual(get_client_ip(req), "192.168.1.1")
37+
38+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
39+
def test_attacker_cannot_control_rate_limit_bucket(self):
40+
"""Attacker-injected leftmost IP must NOT be selected (the old [0] bug)."""
41+
req = _make_request(xff_value="evil-rotated-ip, real-client-ip")
42+
ip = get_client_ip(req)
43+
self.assertNotEqual(ip, "evil-rotated-ip",
44+
"Leftmost (attacker-controlled) IP must not be used")
45+
self.assertEqual(ip, "real-client-ip")
46+
47+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
48+
def test_whitespace_is_stripped(self):
49+
req = _make_request(xff_value="10.0.0.1, 192.168.1.1 ")
50+
self.assertEqual(get_client_ip(req), "192.168.1.1")
51+
52+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
53+
def test_no_xff_header_falls_back_to_client(self):
54+
req = _make_request(client_host="172.16.0.1")
55+
self.assertEqual(get_client_ip(req), "172.16.0.1")
56+
57+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
58+
def test_no_xff_no_client_returns_localhost(self):
59+
req = _make_request()
60+
self.assertEqual(get_client_ip(req), "127.0.0.1")
61+
62+
# --- TRUST_X_FORWARDED_FOR disabled (default) ---
63+
64+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "false"})
65+
def test_xff_ignored_when_trust_disabled(self):
66+
"""XFF header should be completely ignored when trust is off."""
67+
req = _make_request(xff_value="1.2.3.4", client_host="10.0.0.5")
68+
self.assertEqual(get_client_ip(req), "10.0.0.5")
69+
70+
@patch.dict(os.environ, {}, clear=False)
71+
def test_xff_ignored_when_env_unset(self):
72+
"""If TRUST_X_FORWARDED_FOR is not set, default to not trusting."""
73+
env = os.environ.copy()
74+
env.pop("TRUST_X_FORWARDED_FOR", None)
75+
with patch.dict(os.environ, env, clear=True):
76+
req = _make_request(xff_value="1.2.3.4", client_host="10.0.0.5")
77+
self.assertEqual(get_client_ip(req), "10.0.0.5")
78+
79+
# --- Edge cases ---
80+
81+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "true"})
82+
def test_empty_xff_header(self):
83+
"""Empty XFF string should fall back to client."""
84+
req = SimpleNamespace(headers={"X-Forwarded-For": ""}, client=SimpleNamespace(host="10.0.0.1"))
85+
self.assertEqual(get_client_ip(req), "10.0.0.1")
86+
87+
@patch.dict(os.environ, {"TRUST_X_FORWARDED_FOR": "TRUE"})
88+
def test_case_insensitive_trust_flag(self):
89+
"""TRUST_X_FORWARDED_FOR=TRUE (uppercase) should still work."""
90+
req = _make_request(xff_value="1.1.1.1, 2.2.2.2")
91+
self.assertEqual(get_client_ip(req), "2.2.2.2")
92+
93+
94+
if __name__ == "__main__":
95+
unittest.main()

0 commit comments

Comments
 (0)