7bbc944ae2
Deploy Server / release-deploy-server (push) Successful in 51s
服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口; 登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP; 门店 custom_fields 轻量配置(录入默认值)透传保存。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/wangjia/jiu/backend/config"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
}
|
|
|
|
// enableRateLimit 临时打开限流开关,返回恢复函数。
|
|
func enableRateLimit(t *testing.T) {
|
|
t.Helper()
|
|
prev := config.C.RateLimit.Enabled
|
|
config.C.RateLimit.Enabled = true
|
|
t.Cleanup(func() { config.C.RateLimit.Enabled = prev })
|
|
}
|
|
|
|
// hit 发一个带指定客户端 IP 的请求,返回状态码。SetTrustedProxies 让 X-Forwarded-For 被采信,
|
|
// 便于在测试里模拟不同来源 IP。
|
|
func hit(r *gin.Engine, ip string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.RemoteAddr = ip + ":12345"
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func newIPRouter(h gin.HandlerFunc) *gin.Engine {
|
|
r := gin.New()
|
|
r.GET("/", h, func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
return r
|
|
}
|
|
|
|
func TestRateLimitByIP_BurstThen429(t *testing.T) {
|
|
enableRateLimit(t)
|
|
// 每分钟 3 次,burst 3:前 3 个放行,第 4 个 429。
|
|
r := newIPRouter(RateLimitByIP(PerMinute(3), 3))
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if w := hit(r, "1.1.1.1"); w.Code != http.StatusOK {
|
|
t.Fatalf("第 %d 个请求应放行,得到 %d", i+1, w.Code)
|
|
}
|
|
}
|
|
w := hit(r, "1.1.1.1")
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("超出 burst 应 429,得到 %d", w.Code)
|
|
}
|
|
if ra := w.Header().Get("Retry-After"); ra == "" {
|
|
t.Fatal("429 响应应带 Retry-After 头")
|
|
}
|
|
}
|
|
|
|
func TestRateLimitByIP_KeysIndependent(t *testing.T) {
|
|
enableRateLimit(t)
|
|
r := newIPRouter(RateLimitByIP(PerMinute(1), 1))
|
|
|
|
// 不同 IP 各有独立令牌桶,互不影响。
|
|
if w := hit(r, "2.2.2.2"); w.Code != http.StatusOK {
|
|
t.Fatalf("IP A 首次应放行,得到 %d", w.Code)
|
|
}
|
|
if w := hit(r, "3.3.3.3"); w.Code != http.StatusOK {
|
|
t.Fatalf("IP B 首次应放行(独立桶),得到 %d", w.Code)
|
|
}
|
|
// IP A 再来一发应 429(桶已空)。
|
|
if w := hit(r, "2.2.2.2"); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("IP A 第二发应 429,得到 %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitByIP_Refill(t *testing.T) {
|
|
enableRateLimit(t)
|
|
// 每秒 50 次:耗尽 burst 后等一小会儿令牌补回,能再次放行。
|
|
r := newIPRouter(RateLimitByIP(rate.Limit(50), 1))
|
|
if w := hit(r, "4.4.4.4"); w.Code != http.StatusOK {
|
|
t.Fatalf("首发应放行,得到 %d", w.Code)
|
|
}
|
|
if w := hit(r, "4.4.4.4"); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("紧接第二发应 429,得到 %d", w.Code)
|
|
}
|
|
time.Sleep(40 * time.Millisecond) // 50/s → 20ms 补一个令牌
|
|
if w := hit(r, "4.4.4.4"); w.Code != http.StatusOK {
|
|
t.Fatalf("等待补充后应放行,得到 %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitDisabledPassthrough(t *testing.T) {
|
|
prev := config.C.RateLimit.Enabled
|
|
config.C.RateLimit.Enabled = false
|
|
defer func() { config.C.RateLimit.Enabled = prev }()
|
|
|
|
r := newIPRouter(RateLimitByIP(PerMinute(1), 1))
|
|
// 关闭时远超额度也全放行。
|
|
for i := 0; i < 10; i++ {
|
|
if w := hit(r, "5.5.5.5"); w.Code != http.StatusOK {
|
|
t.Fatalf("限流关闭时第 %d 发应放行,得到 %d", i+1, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyedLimiterSweepEvictsIdle(t *testing.T) {
|
|
kl := newKeyedLimiter(PerMinute(60), 1)
|
|
kl.get("ip:a")
|
|
kl.get("ip:b")
|
|
if len(kl.entries) != 2 {
|
|
t.Fatalf("应有 2 个 entry,得到 %d", len(kl.entries))
|
|
}
|
|
// 把 a 的活动时间推到很久以前,sweep 应只淘汰 a。
|
|
kl.mu.Lock()
|
|
kl.entries["ip:a"].lastSeen = time.Now().Add(-time.Hour)
|
|
kl.mu.Unlock()
|
|
|
|
kl.sweep(10 * time.Minute)
|
|
|
|
kl.mu.Lock()
|
|
defer kl.mu.Unlock()
|
|
if _, ok := kl.entries["ip:a"]; ok {
|
|
t.Fatal("空闲 key a 应被淘汰")
|
|
}
|
|
if _, ok := kl.entries["ip:b"]; !ok {
|
|
t.Fatal("活跃 key b 不应被淘汰")
|
|
}
|
|
}
|
|
|
|
// TestTrustedProxyRealIP 复刻 main.go 的可信代理配置:只信任本机写的 X-Real-IP,
|
|
// 客户端伪造的 X-Forwarded-For 不被采信 → c.ClientIP() 返回真实 IP,限流不可被请求头绕过。
|
|
func TestTrustedProxyRealIP(t *testing.T) {
|
|
r := gin.New()
|
|
_ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"})
|
|
r.RemoteIPHeaders = []string{"X-Real-IP"}
|
|
var got string
|
|
r.GET("/", func(c *gin.Context) {
|
|
got = c.ClientIP()
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.RemoteAddr = "127.0.0.1:9999" // 本机 nginx
|
|
req.Header.Set("X-Real-IP", "9.9.9.9") // nginx 写入的真实 IP
|
|
req.Header.Set("X-Forwarded-For", "1.2.3.4") // 客户端伪造,应被忽略
|
|
r.ServeHTTP(w, req)
|
|
|
|
if got != "9.9.9.9" {
|
|
t.Fatalf("ClientIP 应取可信的 X-Real-IP=9.9.9.9,得到 %q(伪造的 XFF 不应生效)", got)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitByShop_NoShopPassthrough(t *testing.T) {
|
|
enableRateLimit(t)
|
|
// 未设置 shop_id(无 JWT 上下文)时放行,交由 JWT 中间件拦截。
|
|
r := gin.New()
|
|
r.GET("/", RateLimitByShop(PerSecond(1), 1), func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
for i := 0; i < 5; i++ {
|
|
if w := hit(r, "6.6.6.6"); w.Code != http.StatusOK {
|
|
t.Fatalf("无 shop_id 时第 %d 发应放行,得到 %d", i+1, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRateLimitByShop_PerShop(t *testing.T) {
|
|
enableRateLimit(t)
|
|
r := gin.New()
|
|
// 模拟 JWT 已注入 shop_id(取自 ?shop= 查询参数)。
|
|
r.GET("/", func(c *gin.Context) {
|
|
id, _ := strconv.ParseUint(c.Query("shop"), 10, 64)
|
|
c.Set(CtxShopID, id)
|
|
}, RateLimitByShop(PerSecond(1), 1), func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
|
|
shopHit := func(shop string) int {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/?shop="+shop, nil)
|
|
r.ServeHTTP(w, req)
|
|
return w.Code
|
|
}
|
|
if c := shopHit("1"); c != http.StatusOK {
|
|
t.Fatalf("shop1 首发应放行,得到 %d", c)
|
|
}
|
|
if c := shopHit("2"); c != http.StatusOK {
|
|
t.Fatalf("shop2 首发应放行(独立桶),得到 %d", c)
|
|
}
|
|
if c := shopHit("1"); c != http.StatusTooManyRequests {
|
|
t.Fatalf("shop1 第二发应 429,得到 %d", c)
|
|
}
|
|
}
|