chore: CI/CD + 前端测试 + 安全加固 + 限流完善
- 新增 .gitlab-ci.yml (lint/test/build 三阶段) - 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16) - 修复旧测试: Sidebar 导航文案、MobileLayout padding 值 - python-jose → PyJWT 消除 ecdsa CVE 漏洞 - 限流中间件增加 5 个敏感端点精细限流 + 标准限流头 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3a444864ac
commit
a2f6f82e15
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
简单的速率限制中间件
|
||||
基于内存的滑动窗口计数器
|
||||
速率限制中间件
|
||||
基于内存的滑动窗口计数器,支持按路径自定义限制和标准响应头。
|
||||
"""
|
||||
import time
|
||||
from collections import defaultdict
|
||||
@@ -14,50 +14,111 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
速率限制中间件
|
||||
|
||||
- 默认: 60 次/分钟 per IP
|
||||
- 登录/注册: 10 次/分钟 per IP
|
||||
- 按路径配置不同限制 (path_limits)
|
||||
- 返回标准 X-RateLimit-* 响应头
|
||||
"""
|
||||
|
||||
def __init__(self, app, default_limit: int = 60, window_seconds: int = 60):
|
||||
# Path-specific rate limits (requests per window).
|
||||
# Paths not listed here fall back to ``default_limit``.
|
||||
DEFAULT_PATH_LIMITS: dict[str, int] = {
|
||||
# Auth endpoints — prevent brute-force / abuse
|
||||
"/api/v1/auth/login": 10,
|
||||
"/api/v1/auth/register": 10,
|
||||
"/api/v1/auth/send-code": 5,
|
||||
"/api/v1/auth/reset-password": 5,
|
||||
# Upload — bandwidth / storage cost
|
||||
"/api/v1/upload/policy": 30,
|
||||
# AI review — service cost + compute
|
||||
"/api/v1/scripts/review": 10,
|
||||
"/api/v1/videos/review": 5,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
default_limit: int = 60,
|
||||
window_seconds: int = 60,
|
||||
path_limits: dict[str, int] | None = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
self.default_limit = default_limit
|
||||
self.window_seconds = window_seconds
|
||||
self.requests: dict[str, list[float]] = defaultdict(list)
|
||||
# Stricter limits for auth endpoints
|
||||
self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"}
|
||||
self.strict_limit = 10
|
||||
# Merge caller-supplied overrides on top of the built-in defaults.
|
||||
self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
|
||||
if path_limits:
|
||||
self.path_limits.update(path_limits)
|
||||
|
||||
def _get_limit(self, path: str) -> int:
|
||||
"""Return the rate limit for *path*, falling back to *default_limit*."""
|
||||
return self.path_limits.get(path, self.default_limit)
|
||||
|
||||
def _make_key(self, client_ip: str, path: str) -> str:
|
||||
"""Build the bucket key.
|
||||
|
||||
Paths with a custom limit are bucketed per-IP per-path so that
|
||||
hitting one endpoint does not consume the quota of another.
|
||||
Default paths share a single per-IP bucket.
|
||||
"""
|
||||
if path in self.path_limits:
|
||||
return f"{client_ip}:{path}"
|
||||
return client_ip
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
path = request.url.path
|
||||
now = time.time()
|
||||
|
||||
# Determine rate limit
|
||||
if path in self.strict_paths:
|
||||
key = f"{client_ip}:{path}"
|
||||
limit = self.strict_limit
|
||||
else:
|
||||
key = client_ip
|
||||
limit = self.default_limit
|
||||
limit = self._get_limit(path)
|
||||
key = self._make_key(client_ip, path)
|
||||
|
||||
# Clean old entries
|
||||
# Clean old entries outside the sliding window
|
||||
window_start = now - self.window_seconds
|
||||
self.requests[key] = [t for t in self.requests[key] if t > window_start]
|
||||
|
||||
current_count = len(self.requests[key])
|
||||
remaining = max(0, limit - current_count)
|
||||
|
||||
# Seconds until the oldest request in the window expires
|
||||
if self.requests[key]:
|
||||
reset_seconds = int(self.requests[key][0] - window_start)
|
||||
else:
|
||||
reset_seconds = self.window_seconds
|
||||
|
||||
# Build common rate-limit headers
|
||||
rate_headers = {
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": str(max(0, remaining - 1) if remaining > 0 else 0),
|
||||
"X-RateLimit-Reset": str(reset_seconds),
|
||||
}
|
||||
|
||||
# Check limit
|
||||
if len(self.requests[key]) >= limit:
|
||||
if current_count >= limit:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后再试"},
|
||||
headers={
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_seconds),
|
||||
"Retry-After": str(reset_seconds),
|
||||
},
|
||||
)
|
||||
|
||||
# Record request
|
||||
self.requests[key].append(now)
|
||||
|
||||
# Periodic cleanup (every 1000 requests to this key)
|
||||
# Periodic cleanup (keep memory bounded)
|
||||
if len(self.requests) > 10000:
|
||||
self._cleanup(now)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Attach rate-limit headers to successful responses
|
||||
response.headers["X-RateLimit-Limit"] = rate_headers["X-RateLimit-Limit"]
|
||||
response.headers["X-RateLimit-Remaining"] = rate_headers["X-RateLimit-Remaining"]
|
||||
response.headers["X-RateLimit-Reset"] = rate_headers["X-RateLimit-Reset"]
|
||||
|
||||
return response
|
||||
|
||||
def _cleanup(self, now: float):
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import secrets
|
||||
from jose import jwt, JWTError
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -14,7 +14,7 @@ dependencies = [
|
||||
"httpx>=0.26.0",
|
||||
"pydantic[email]>=2.5.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-jose>=3.3.0",
|
||||
"PyJWT>=2.8.0",
|
||||
"passlib>=1.7.4",
|
||||
"alembic>=1.13.0",
|
||||
"cryptography>=42.0.0",
|
||||
@@ -57,6 +57,7 @@ markers = [
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::jwt.warnings.InsecureKeyLengthWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
Reference in New Issue
Block a user