用户可高频刷面板接口(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 守卫全部通过。
164 lines
5.4 KiB
Go
164 lines
5.4 KiB
Go
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")
|
||
}
|