mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 23:53:33 +08:00
用户可高频刷面板接口(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 守卫全部通过。
227 lines
7.0 KiB
Go
227 lines
7.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestWindowTTLMillis(t *testing.T) {
|
|
require.Equal(t, int64(1), windowTTLMillis(500*time.Microsecond))
|
|
require.Equal(t, int64(1), windowTTLMillis(1500*time.Microsecond))
|
|
require.Equal(t, int64(2), windowTTLMillis(2500*time.Microsecond))
|
|
}
|
|
|
|
func TestRateLimiterFailureModes(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
rdb := redis.NewClient(&redis.Options{
|
|
Addr: "127.0.0.1:1",
|
|
DialTimeout: 50 * time.Millisecond,
|
|
ReadTimeout: 50 * time.Millisecond,
|
|
WriteTimeout: 50 * time.Millisecond,
|
|
})
|
|
t.Cleanup(func() {
|
|
_ = rdb.Close()
|
|
})
|
|
|
|
limiter := NewRateLimiter(rdb)
|
|
|
|
failOpenRouter := gin.New()
|
|
failOpenRouter.Use(limiter.Limit("test", 1, time.Second))
|
|
failOpenRouter.GET("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
recorder := httptest.NewRecorder()
|
|
failOpenRouter.ServeHTTP(recorder, req)
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
failCloseRouter := gin.New()
|
|
failCloseRouter.Use(limiter.LimitWithOptions("test", 1, time.Second, RateLimitOptions{
|
|
FailureMode: RateLimitFailClose,
|
|
}))
|
|
failCloseRouter.GET("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
})
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
recorder = httptest.NewRecorder()
|
|
failCloseRouter.ServeHTTP(recorder, req)
|
|
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
|
|
}
|
|
|
|
func TestRateLimiterDifferentIPsIndependent(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
callCounts := make(map[string]int64)
|
|
originalRun := rateLimitRun
|
|
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
|
|
callCounts[key]++
|
|
return callCounts[key], false, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
rateLimitRun = originalRun
|
|
})
|
|
|
|
limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}))
|
|
|
|
router := gin.New()
|
|
router.Use(limiter.Limit("api", 1, time.Second))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
})
|
|
|
|
// 第一个 IP 的请求应通过
|
|
req1 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req1.RemoteAddr = "10.0.0.1:1234"
|
|
rec1 := httptest.NewRecorder()
|
|
router.ServeHTTP(rec1, req1)
|
|
require.Equal(t, http.StatusOK, rec1.Code, "第一个 IP 的第一次请求应通过")
|
|
|
|
// 第二个 IP 的请求应独立通过(不受第一个 IP 的计数影响)
|
|
req2 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req2.RemoteAddr = "10.0.0.2:5678"
|
|
rec2 := httptest.NewRecorder()
|
|
router.ServeHTTP(rec2, req2)
|
|
require.Equal(t, http.StatusOK, rec2.Code, "第二个 IP 的第一次请求应独立通过")
|
|
|
|
// 第一个 IP 的第二次请求应被限流
|
|
req3 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req3.RemoteAddr = "10.0.0.1:1234"
|
|
rec3 := httptest.NewRecorder()
|
|
router.ServeHTTP(rec3, req3)
|
|
require.Equal(t, http.StatusTooManyRequests, rec3.Code, "第一个 IP 的第二次请求应被限流")
|
|
}
|
|
|
|
func TestRateLimiterAllow(t *testing.T) {
|
|
originalRun := rateLimitRun
|
|
var gotKey string
|
|
count := int64(0)
|
|
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
|
|
gotKey = key
|
|
count++
|
|
return count, false, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
rateLimitRun = originalRun
|
|
})
|
|
|
|
// PTTL 走真实客户端(不可达地址)→ 失败后 RetryAfter 应回退为完整窗口
|
|
limiter := NewRateLimiter(redis.NewClient(&redis.Options{
|
|
Addr: "127.0.0.1:1",
|
|
DialTimeout: 50 * time.Millisecond,
|
|
ReadTimeout: 50 * time.Millisecond,
|
|
WriteTimeout: 50 * time.Millisecond,
|
|
}))
|
|
|
|
res, err := limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute)
|
|
require.NoError(t, err)
|
|
require.True(t, res.Allowed)
|
|
require.Equal(t, int64(1), res.Count)
|
|
require.Zero(t, res.RetryAfter)
|
|
require.Equal(t, "rate_limit:panel:global:user:42", gotKey)
|
|
|
|
res, err = limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute)
|
|
require.NoError(t, err)
|
|
require.False(t, res.Allowed)
|
|
require.Equal(t, int64(2), res.Count)
|
|
require.Equal(t, time.Minute, res.RetryAfter)
|
|
}
|
|
|
|
func TestRateLimiterHonorsForwardedIPSnapshot(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
callCounts := make(map[string]int64)
|
|
originalRun := rateLimitRun
|
|
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
|
|
callCounts[key]++
|
|
return callCounts[key], false, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
rateLimitRun = originalRun
|
|
})
|
|
|
|
limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}))
|
|
|
|
router := gin.New()
|
|
// 模拟 SessionBindingContext:开启转发 IP 兼容模式快照
|
|
router.Use(func(c *gin.Context) {
|
|
ippkg.SetForwardedIPSettings(c, true, nil)
|
|
c.Next()
|
|
})
|
|
router.Use(limiter.Limit("fwd", 1, time.Second))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
})
|
|
|
|
send := func(xff string) int {
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
// 所有请求都来自同一个反代地址
|
|
req.RemoteAddr = "127.0.0.1:5678"
|
|
req.Header.Set("X-Forwarded-For", xff)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// 反代后两个不同的真实客户端应各自独立计数,不因共享代理地址被合并限流
|
|
require.Equal(t, http.StatusOK, send("198.51.100.1"))
|
|
require.Equal(t, http.StatusOK, send("198.51.100.2"))
|
|
// 同一真实客户端第二次请求应被限流
|
|
require.Equal(t, http.StatusTooManyRequests, send("198.51.100.1"))
|
|
|
|
require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.1")
|
|
require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.2")
|
|
}
|
|
|
|
func TestRateLimiterSuccessAndLimit(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
originalRun := rateLimitRun
|
|
counts := []int64{1, 2}
|
|
callIndex := 0
|
|
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
|
|
if callIndex >= len(counts) {
|
|
return counts[len(counts)-1], false, nil
|
|
}
|
|
value := counts[callIndex]
|
|
callIndex++
|
|
return value, false, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
rateLimitRun = originalRun
|
|
})
|
|
|
|
limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}))
|
|
|
|
router := gin.New()
|
|
router.Use(limiter.Limit("test", 1, time.Second))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
recorder = httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, req)
|
|
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
|
|
}
|