Skip to content

Commit 4505267

Browse files
committed
feat(security): 登录入口与回调轨道加固(阶段2)
- 登录限流与审计 IP 统一走 client_ip/trust_proxy(deps.request_ip): 反代部署下原来全站共享一个 IP 限流桶、审计全是代理地址 - 登录端点加 CSRF 校验(login CSRF);无会话 cookie 的请求跳过, curl/首次登录不受影响 - TRAE 待完成回调加 600s TTL:/authorize 无鉴权,长期挂着的 pending 可被同网络任何人用自己的 refreshToken 完成兑换(凭证入池) - 移除 app.state.last_callback_url:含 refreshToken 的完整回调 URL 不再无谓驻留内存 - /docs 与 /openapi.json 默认关闭(ENABLE_DOCS=true 显式打开): 匿名不再能拉全量 API 结构;实测 openapi 路径已由 SPA 壳接管 - https 部署自动下发 HSTS;CSP 维持 frame-ancestors(前端主题内联 脚本依赖 'unsafe-inline',default-src 收益为零,不做) - APP_SECRET 维持既有 ≥16 校验不加码:阈值提高会强制密钥轮换, 而 APP_SECRET 丢失=已存凭证全部作废 - compose 透传 ENABLE_DOCS;README 同步
1 parent ed77879 commit 4505267

10 files changed

Lines changed: 141 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
251251
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值:剩余低于该值时标红;`≤0` 关闭预警(仍显示剩余时间)。纯展示,不参与调度 |
252252
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒) |
253253
| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO 级,调到 `WARNING` 会一并关掉 |
254+
| `ENABLE_DOCS` | `false` | 是否暴露 `/docs` 与 `/openapi.json`(默认关闭:匿名可拉全量 API 结构);本地调试需 Swagger 时置 `true` |
254255
| `DUMP_REQUEST_BODIES` | `false` | 诊断:把 `/v1` 原始请求体落盘到 `data/dumps/`(**含对话内容**,仅排查用) |
255256
| `AUTO_CONTINUE_MAX` | `10` | 上游以 `finish_reason=length` 截断时同凭证自动续写的最多次数;`0` 关闭(见 TECHNICAL.md §3.4) |
256257
| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | 非流式聚合整体超时(秒):上游连接半开停滞会让非流式请求无限悬挂并占住凭证,超时按瞬态错误换号重试(流式路径有心跳兜底不受影响);`≤0` 关闭 |

‎docker-compose.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ services:
5757
# 截断续写:上游 finish_reason=length 时同凭证自动续写的最多次数;0 关闭
5858
AUTO_CONTINUE_MAX: ${AUTO_CONTINUE_MAX:-10}
5959
UPSTREAM_COMPLETE_TIMEOUT_SECONDS: ${UPSTREAM_COMPLETE_TIMEOUT_SECONDS:-600}
60+
ENABLE_DOCS: ${ENABLE_DOCS:-false}
6061
# 服务监听地址(容器内必须 0.0.0.0 才能被映射访问)
6162
HOST: ${HOST:-0.0.0.0}
6263
PORT: ${PORT:-8000}

‎src/api/admin_auth.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from __future__ import annotations
88

9+
import time
10+
911
from fastapi import APIRouter, Depends, Request
1012
from fastapi.responses import JSONResponse
1113

@@ -19,7 +21,7 @@
1921
from ..auth.users import create_password_hash, verify_password
2022
from ..compat.openai.request import InvalidRequest
2123
from ..config import Settings
22-
from .deps import SESSION_COOKIE, Services, csrf_protected, principal_from_request
24+
from .deps import SESSION_COOKIE, Services, csrf_protected, principal_from_request, request_ip
2325

2426
MIN_PASSWORD_LENGTH = 8
2527
ACTIVATION_TTL_SECONDS = 24 * 3600
@@ -52,10 +54,14 @@ def create_router(services: Services) -> APIRouter:
5254
router = APIRouter()
5355

5456
@router.post("/api/auth/login")
55-
async def login(request: Request, payload: dict):
57+
async def login(request: Request, payload: dict,
58+
_csrf: None = Depends(csrf_protected)):
59+
# CSRF 校验对登录同样生效(login CSRF:跨站请求可把受害者登录到
60+
# 攻击者账号);无会话 cookie 的请求会被 csrf_protected 跳过,
61+
# 因此 curl / 首次登录不受影响。
5662
username = str(payload.get("username") or "")
5763
password = str(payload.get("password") or "")
58-
ip = request.client.host if request.client else ""
64+
ip = request_ip(request, services.settings)
5965
throttle = services.login_throttle
6066
# 哈希前先卡全局/IP 窗口:PBKDF2(600k 迭代)是 CPU 密集操作,
6167
# 不限流的话无效尝试就能占满线程池(DoS)。
@@ -117,7 +123,7 @@ async def change_password(request: Request, payload: dict,
117123
services.audit.record(actor=principal.username,
118124
action=ACTION_USER_PASSWORD_CHANGE,
119125
target=principal.username,
120-
ip=request.client.host if request.client else None)
126+
ip=request_ip(request, services.settings) or None)
121127
# 改密会 bump epoch:换发一张新 Cookie,否则本次请求返回后自己也被登出。
122128
response = JSONResponse({"ok": True})
123129
role = services.users.role_of(principal.username) or ""
@@ -142,6 +148,7 @@ async def upstream_auth_start(request: Request, payload: dict,
142148
raise InvalidRequest(f"provider {provider_id!r} does not support login")
143149
session = builder(resolve_public_callback_url(services.settings))
144150
request.app.state.pending_callback_state = session.state
151+
request.app.state.pending_callback_at = time.monotonic()
145152
request.app.state.pending_callback_user = principal.username
146153
# 回调轨道没有本地轮询:登录结果由 /authorize 落库后由前端查凭证列表
147154
return {"flow": session.flow, "state": session.state, "auth_url": session.auth_url,

‎src/api/authorize.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
from ..provider.trae.events import UpstreamProtocolViolation
1212
from .deps import Services
1313

14+
# 待完成回调的有效期:/authorize 无鉴权(浏览器 302 不带 key),长期挂着
15+
# 的 pending 会被同网络的任何人都可用自己的 refreshToken 完成兑换——
16+
# 换句话说,谁都能把自己的凭证塞进池子(落库归属还是发起登录的管理员)。
17+
# 10 分钟足够走完一次浏览器登录。
18+
PENDING_CALLBACK_TTL_SECONDS = 600
19+
1420

1521
def create_router(services: Services) -> APIRouter:
1622
router = APIRouter()
@@ -21,6 +27,7 @@ async def authorize(request: Request):
2127
2228
回调不需要 API Key(浏览器不会带),因此这里不做鉴权,但:
2329
- 只在存在待完成的登录时接受回调(防止任意链接被塞进池子)
30+
- 待完成登录有 TTL,过期即作废并清槽
2431
- 只接受带 refreshToken 的链接,其他一律拒绝
2532
- 换到的凭证直接落库,响应里绝不回传 token
2633
@@ -29,12 +36,18 @@ async def authorize(request: Request):
2936
这正是 start_auth 把它们编码进 state 的原因(保证登录与落盘凭证一致)。
3037
"""
3138
raw = str(request.url)
32-
request.app.state.last_callback_url = raw
3339
provider = services.registry.get("trae")
3440
pending = request.app.state.pending_callback_state
41+
started_at = getattr(request.app.state, "pending_callback_at", None)
3542
if provider is None or pending is None:
3643
return JSONResponse(status_code=400, content=error_payload(
3744
"no pending TRAE login in progress", "invalid_request", 400))
45+
if started_at is not None and (time.monotonic() - started_at
46+
> PENDING_CALLBACK_TTL_SECONDS):
47+
request.app.state.pending_callback_state = None
48+
return JSONResponse(status_code=400, content=error_payload(
49+
"pending TRAE login expired, start a new one",
50+
"invalid_request", 400))
3851
if not request.query_params.get("refreshToken") and not request.query_params.get("userJwt"):
3952
return JSONResponse(status_code=400, content=error_payload(
4053
"callback missing refreshToken", "invalid_request", 400))

‎src/api/deps.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ def get_services(request: Request) -> Services:
9292
return request.app.state.services
9393

9494

95+
def request_ip(request: Request, settings: Settings | RuntimeSettings) -> str:
96+
"""来源 IP 统一入口:受信反代(trust_proxy)取 XFF 最后条目,否则取对端。
97+
98+
限流与审计必须走这里与 api_key_user 同源:反代部署下拿裸对端地址,
99+
全站请求会共享同一个 IP 限流桶(互相锁死),审计里也全是代理 IP。
100+
"""
101+
return client_ip(request.client.host if request.client else None,
102+
request.headers.get("x-forwarded-for"),
103+
trust_proxy=bool(settings.trust_proxy))
104+
105+
95106
async def principal_from_request(request: Request) -> Principal:
96107
"""会话 Cookie → Principal(管理台内部端点)。
97108
@@ -154,9 +165,7 @@ async def api_key_user(request: Request) -> ApiKeyPrincipal:
154165
# 用户被删除或禁用后旧 Key 必须立即失效(同会话 Cookie 的理由)
155166
if not record or not services.users.is_active(record["username"]):
156167
raise UnauthorizedError("invalid api key")
157-
source = client_ip(request.client.host if request.client else None,
158-
request.headers.get("x-forwarded-for"),
159-
trust_proxy=bool(services.settings.trust_proxy))
168+
source = request_ip(request, services.settings)
160169
if not ip_allowed(source, record.get("allowed_ips") or ""):
161170
raise ForbiddenError("source ip not allowed for this api key")
162171
return ApiKeyPrincipal(username=record["username"], key_id=record["id"],

‎src/config.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
3232
log_level: str = "INFO"
3333
# Host 白名单(防 DNS rebinding):逗号分隔;空 = 本地默认 + PUBLIC_BASE_URL 主机
3434
allowed_hosts: str = ""
35+
# 是否暴露 /docs 与 /openapi.json:默认关闭(匿名可拉全量 API 结构)。
36+
# 本地调试需要 Swagger 时置 true
37+
enable_docs: bool = False
3538
# 是否采信 X-Forwarded-For 判定来源 IP(API Key 的 allowed_ips 白名单用)。
3639
# 默认 false:XFF 由客户端可写,直连部署下信它等于白名单形同虚设。
3740
# 仅在「本服务前面恰好一层受信反代」时开启,届时取 XFF 最后一个条目。

‎src/main.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@ async def lifespan(app_: FastAPI):
209209
await closer()
210210
db.close()
211211

212-
app = FastAPI(title="Coding2API", version=app_version(), lifespan=lifespan)
212+
app = FastAPI(title="Coding2API", version=app_version(), lifespan=lifespan,
213+
# API 结构不对外暴露:匿名可拉全量端点清单等于送侦察图。
214+
# 本地调试需要 Swagger 时 ENABLE_DOCS=true 显式打开。
215+
redoc_url=None,
216+
docs_url="/docs" if config.enable_docs else None,
217+
openapi_url="/openapi.json" if config.enable_docs else None)
213218
# BodySizeLimitMiddleware 必须在最外层:FastAPI.add_middleware 会把后加
214219
# 的包在更外层,所以它在最后添加(见 build_app 末尾)。
215220
app.state.settings = config

‎src/webapp/security.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,8 @@ async def security_middleware(request: Request, call_next):
5151
response.headers.setdefault("X-Content-Type-Options", "nosniff")
5252
response.headers.setdefault("X-Frame-Options", "DENY")
5353
response.headers.setdefault("Content-Security-Policy", "frame-ancestors 'none'")
54+
if request.app.state.settings.public_base_url.startswith("https://"):
55+
# 仅 https 部署下发 HSTS:明文部署发了无意义,还会预锁本地 http 访问
56+
response.headers.setdefault("Strict-Transport-Security",
57+
"max-age=31536000; includeSubDomains")
5458
return response

‎tests/test_m1a_trae.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,19 @@ def test_authorize_rejects_callback_without_pending_login(client):
11351135
response = client.get("/authorize?refreshToken=RT")
11361136
assert response.status_code == 400
11371137
assert response.json()["error"]["code"] == "invalid_request"
1138-
assert "refreshToken=RT" in client.app.state.last_callback_url
1138+
1139+
1140+
def test_authorize_expires_stale_pending_login(client):
1141+
"""待完成登录有 TTL:/authorize 无鉴权,长期挂着的 pending 会被同网络
1142+
任何人用自己的 refreshToken 完成兑换(凭证塞进池子)。过期即作废。"""
1143+
import time as _time
1144+
1145+
client.app.state.pending_callback_state = "machine:device"
1146+
client.app.state.pending_callback_at = _time.monotonic() - 601
1147+
response = client.get("/authorize?refreshToken=RT")
1148+
assert response.status_code == 400
1149+
assert "expired" in response.json()["error"]["message"]
1150+
assert client.app.state.pending_callback_state is None
11391151

11401152

11411153
def test_prepare_body_stringifies_tool_parameters():

‎tests/test_security.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,82 @@ def test_security_headers_present(app):
196196
assert response.headers["content-security-policy"] == "frame-ancestors 'none'"
197197

198198

199+
def test_hsts_only_for_https_deployments(tmp_path):
200+
"""HSTS 仅 https 部署下发:明文场景发了无意义,还会预锁本地 http 访问。"""
201+
https_app = build_app(Settings(
202+
_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path),
203+
ADMIN_USERNAMES="root", PUBLIC_BASE_URL="https://gw.example.com"))
204+
with TestClient(https_app) as client:
205+
assert client.get("/health").headers["strict-transport-security"] \
206+
.startswith("max-age=")
207+
208+
209+
def test_hsts_absent_for_http_deployment(app):
210+
_app, client = app
211+
assert "strict-transport-security" not in client.get("/health").headers
212+
213+
214+
# ------------------------------------------------------------ 文档端点开关
215+
216+
217+
def test_docs_disabled_by_default(app):
218+
"""默认不开 docs:openapi schema 绝不对外(/docs 与 /openapi.json 由
219+
SPA catch-all 接住返回前端壳,无 API 结构泄漏)。"""
220+
_app, client = app
221+
for path in ("/docs", "/openapi.json"):
222+
response = client.get(path)
223+
assert response.status_code == 200
224+
assert "text/html" in response.headers["content-type"]
225+
assert '"openapi"' not in response.text
226+
227+
228+
def test_docs_opt_in(tmp_path):
229+
application = build_app(Settings(
230+
_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path),
231+
ADMIN_USERNAMES="root", ENABLE_DOCS=True))
232+
with TestClient(application) as client:
233+
assert client.get("/docs").status_code == 200
234+
assert client.get("/openapi.json").status_code == 200
235+
236+
237+
# ------------------------------------------------------------ 登录入口加固
238+
239+
240+
def test_login_ip_respects_trusted_proxy(tmp_path):
241+
"""反代部署(trust_proxy=true)下,登录审计按 XFF 最后条目取来源 IP;
242+
限流桶因此按真实客户端分桶,而不是全站共享代理地址一个桶。"""
243+
from src.audit.actions import ACTION_LOGIN_FAILURE
244+
from src.db.repo import AuditRepository
245+
246+
application = build_app(Settings(
247+
_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path),
248+
ADMIN_USERNAMES="root", TRUST_PROXY=True))
249+
headers = {"X-Forwarded-For": "203.0.113.7, 198.51.100.9"}
250+
with TestClient(application) as client:
251+
assert client.post("/api/auth/login",
252+
json={"username": "root", "password": "bad"},
253+
headers=headers).status_code == 401
254+
audit = AuditRepository(application.state.services.audit._db)
255+
rows = audit.query(action=ACTION_LOGIN_FAILURE, limit=1)
256+
assert rows and rows[0]["ip"] == "198.51.100.9"
257+
258+
259+
def test_login_csrf_rejected_with_session_cookie_and_cross_origin(admin_client):
260+
"""login CSRF:带会话 cookie 的跨站登录请求一并拦截;无 cookie 的
261+
登录(curl / 首次登录)不受影响(由 csrf_protected 的跳过逻辑保证)。"""
262+
response = admin_client.post(
263+
"/api/auth/login", json={"username": "root", "password": "bad"},
264+
headers={"Origin": "http://evil.example.com"})
265+
assert response.status_code == 403
266+
267+
268+
def test_login_without_cookie_skips_csrf(app):
269+
_app, client = app
270+
# 无会话 cookie:非浏览器客户端直接放行到密码校验(401 而非 403)
271+
assert client.post("/api/auth/login",
272+
json={"username": "root", "password": "bad"}).status_code == 401
273+
274+
199275
# ------------------------------------------------------------- Host 白名单
200276

201277

0 commit comments

Comments
 (0)