Files
sub2api/backend/internal/server/middleware/panel_rate_limit.go
T
shaw fead4c7ec3 feat(security): add panel API rate limiting to protect DB from high-frequency requests
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。

三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):

1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
   (所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
   - Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
   - Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
     /user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
   - 管理员默认豁免(可关闭)

2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
   限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
   一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。

3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
   会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
   IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
   所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
   DoS;开关关闭时行为与原来完全一致。

工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
  限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
  新增「面板接口限流」卡片(zh/en i18n 全量)

测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
2026-07-27 15:12:51 +08:00

164 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"log/slog"
"net"
"net/http"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// panelRateLimitWindow 面板限流固定窗口时长(所有档位均按每分钟计数)。
const panelRateLimitWindow = time.Minute
// panelRateLimitAllower 抽象底层限流原语,便于单测注入。
type panelRateLimitAllower interface {
Allow(ctx context.Context, key string, limit int, window time.Duration) (middleware.AllowResult, error)
}
// PanelRateLimiter 面板(管理面 /api/v1)API 限流器。
//
// 设计要点:
// - 认证接口按「用户 ID」维度计数:与客户端 IP 完全无关,反向代理/共享出口
// (所有请求源 IP 坍缩为 127.0.0.1 等)不会互相误伤。
// - 公开接口按安全客户端 IP 计数:仅统计全局单播地址,回环/内网/链路本地
// 地址(反代内部转发地址)直接跳过,避免误拦整条反代链路的流量。
// - 配置走进程内缓存(60s TTL),热路径零 DB 访问。
// - Redis 异常一律 fail-open:限流是保护措施,不能反过来把面板打挂。
type PanelRateLimiter struct {
limiter panelRateLimitAllower
settingService *service.SettingService
}
// NewPanelRateLimiter 创建面板限流器。
func NewPanelRateLimiter(redisClient *redis.Client, settingService *service.SettingService) *PanelRateLimiter {
return &PanelRateLimiter{
limiter: middleware.NewRateLimiter(redisClient),
settingService: settingService,
}
}
// Global 认证面板接口的全局按用户限流(宽松档,覆盖所有登录后端点)。
func (p *PanelRateLimiter) Global() gin.HandlerFunc {
return p.userScoped("global", func(s service.PanelRateLimitSettings) int { return s.UserRPM })
}
// Heavy 重查询接口的按用户限流(严格档,覆盖 usage/dashboard 等聚合统计端点)。
// 与 Global 叠加计数:一次重查询同时消耗两档额度。
func (p *PanelRateLimiter) Heavy() gin.HandlerFunc {
return p.userScoped("heavy", func(s service.PanelRateLimitSettings) int { return s.HeavyRPM })
}
func (p *PanelRateLimiter) userScoped(scope string, limitOf func(service.PanelRateLimitSettings) int) gin.HandlerFunc {
return func(c *gin.Context) {
if p == nil || p.limiter == nil || p.settingService == nil {
c.Next()
return
}
settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context())
if !settings.Enabled {
c.Next()
return
}
limit := limitOf(settings)
if limit <= 0 {
c.Next()
return
}
subject, ok := GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
// 无认证主体(认证中间件缺位时的防御分支):放行,避免误伤
c.Next()
return
}
if settings.ExemptAdmin {
if role, hasRole := GetUserRoleFromContext(c); hasRole && role == service.RoleAdmin {
c.Next()
return
}
}
key := "panel:" + scope + ":user:" + strconv.FormatInt(subject.UserID, 10)
result, err := p.limiter.Allow(c.Request.Context(), key, limit, panelRateLimitWindow)
if err != nil {
// fail-open:Redis 异常不阻断面板访问
slog.Warn("panel rate limit check failed, allowing request", "scope", scope, "error", err)
c.Next()
return
}
if !result.Allowed {
abortPanelRateLimited(c, result.RetryAfter)
return
}
c.Next()
}
}
// PublicIP 无需认证的公开接口按客户端 IP 限流。
// 使用与审计日志/会话绑定一致的安全客户端 IP 解析;解析结果为回环/内网/
// 链路本地地址时跳过计数(这类地址通常是反代内部转发地址,按它计数会把
// 整条反代链路的所有真实用户合并进同一个桶造成大面积误拦截)。
func (p *PanelRateLimiter) PublicIP() gin.HandlerFunc {
return func(c *gin.Context) {
if p == nil || p.limiter == nil || p.settingService == nil {
c.Next()
return
}
settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context())
if !settings.Enabled || settings.PublicIPRPM <= 0 {
c.Next()
return
}
clientIP := SecurityClientIP(c)
if !isPubliclyRoutableClientIP(clientIP) {
c.Next()
return
}
result, err := p.limiter.Allow(c.Request.Context(), "panel:public:ip:"+clientIP, settings.PublicIPRPM, panelRateLimitWindow)
if err != nil {
slog.Warn("panel public rate limit check failed, allowing request", "error", err)
c.Next()
return
}
if !result.Allowed {
abortPanelRateLimited(c, result.RetryAfter)
return
}
c.Next()
}
}
// isPubliclyRoutableClientIP 判断地址是否为可作为限流依据的全局单播地址。
// 回环、RFC1918/ULA 内网、链路本地与未指定地址返回 false。
func isPubliclyRoutableClientIP(clientIP string) bool {
ip := net.ParseIP(clientIP)
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
return ip.IsGlobalUnicast()
}
func abortPanelRateLimited(c *gin.Context, retryAfter time.Duration) {
if retryAfter <= 0 {
retryAfter = panelRateLimitWindow
}
seconds := int64(retryAfter / time.Second)
if retryAfter%time.Second > 0 {
seconds++
}
c.Header("Retry-After", strconv.FormatInt(seconds, 10))
AbortWithError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Too many requests, please slow down and try again later")
}