security: 补上认证面与输入边界的四处防护

链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。

- 认证端点限速:/auth/login、/auth/register 此前完全没接限速器(Limiter 只挂在代理
  Auth 上),爆破与批量建号零成本。新增 AllowWindow 滑动窗口,登录按「单 IP」和
  「单 IP×账号」两个桶(默认 30/min、10/min),注册按 IP(5/min)且在 argon2 之前拦,
  避免光靠建号请求打满 CPU;超限返回 429 + Retry-After。
- 生产启动断言:env=production 时拒绝默认 OT_JWT_SECRET、缺失或短于 16 字节的
  OT_MASTER_KEY(缺失时 NewEncryptor 会用 sha256("") 派生,渠道密文可被公开还原)、
  默认管理员密码;一次列全所有问题,不再改一个试一个。development 不拦。
- 请求体上限:parseBody 原来全量 io.ReadAll 且全项目没有一处 MaxBytesReader,
  合法密钥可把任意大 body 整份压进内存再转发。/v1 组挂 BodyLimit(默认 32MB,
  OT_PROXY_MAX_BODY_MB),超限按客户端协议返回 413 request_too_large 并点名上限。
- CORS:不再无条件反射 Origin + Allow-Credentials: true——那唯一挡住跨站凭据读取的
  是 refresh cookie 的 SameSite=Lax,改成 None 就立刻变成窃取面。改为显式白名单,
  空白名单完全不发跨域头(同源部署与 vite proxy 本来就不需要),通配 * 不配凭据;
  允许头补上 X-Api-Key / Anthropic-Version / OpenAI-*,否则浏览器里跑官方 SDK 预检会挂。
- 配套 OT_SERVER_TRUSTED_PROXIES:不配置就不信 X-Forwarded-For(防伪造头绕过 IP 限速)。

顺手删掉 .env.example 末尾重复的 OT_PROXY_LOG_RAW 段。
This commit is contained in:
Sakurasan committed 2026-10-17 03:25:30 +08:00
1 parent d4c5263f1e
commit ad10bdc400
14 files changed
+521 -28

No files matched your search

+34 -7
View File
@@ -4,6 +4,7 @@ package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@@ -27,6 +28,20 @@ func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
return &Handler{a: a, gw: gw, passkeys: pk}
}
// authLimited 认证端点滑动窗口限速:超限返回 429 并带 Retry-After。
// limit<=0 或限速器缺席时放行。
func (h *Handler) authLimited(c *gin.Context, key string, limit int, window time.Duration) bool {
if h.a.Limit == nil || limit <= 0 {
return false
}
if h.a.Limit.AllowWindow(key, limit, window) {
return false
}
c.Header("Retry-After", strconv.Itoa(int(window.Seconds())))
resp.Fail(c, http.StatusTooManyRequests, "尝试过于频繁,请稍后再试")
return true
}
// ---------------------------------------------------------------------------
// 认证
@@ -39,6 +54,10 @@ type registerReq struct {
// Register POST /api/v1/auth/register
func (h *Handler) Register(c *gin.Context) {
// 先限速再解析/哈希:注册走 argon2,未限速时光靠建号请求就能把 CPU 打满
if h.authLimited(c, "register:ip:"+c.ClientIP(), h.a.Cfg.RateLimit.RegisterIPPerMin, time.Minute) {
return
}
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
@@ -105,6 +124,14 @@ func (h *Handler) Login(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
// 两个桶:单 IP 总量挡扫库式爆破,单 IP×账号挡换 IP 定向爆破同一个账号
ip := c.ClientIP()
if h.authLimited(c, "login:ip:"+ip, h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) {
return
}
if h.authLimited(c, "login:u:"+ip+"|"+strings.ToLower(req.Username), h.a.Cfg.RateLimit.LoginUserPerMin, time.Minute) {
return
}
var u store.User
err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error
if err != nil || u.Status != store.UserStatusActive {
@@ -219,15 +246,15 @@ func (h *Handler) setRefreshCookie(c *gin.Context, value string) {
func (h *Handler) publicUser(u *store.User) gin.H {
return gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"role": u.Role,
"balance": u.Balance,
"status": u.Status,
"id": u.ID,
"username": u.Username,
"email": u.Email,
"role": u.Role,
"balance": u.Balance,
"status": u.Status,
"allowed_models": u.AllowedModels,
"denied_models": u.DeniedModels,
"created_at": u.CreatedAt,
"created_at": u.CreatedAt,
}
}
+127
View File
@@ -0,0 +1,127 @@
package middleware
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newServer(allow []string) *httptest.Server {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS(allow))
r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
r.POST("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
return httptest.NewServer(r)
}
func TestCORSWhitelist(t *testing.T) {
srv := newServer([]string{"http://ok.com"})
defer srv.Close()
get := func(origin string) http.Header {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil)
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
return resp.Header
}
h := get("http://ok.com")
if h.Get("Access-Control-Allow-Origin") != "http://ok.com" {
t.Fatalf("白名单内应回显 origin,实际 %q", h.Get("Access-Control-Allow-Origin"))
}
if h.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatal("白名单内应允许凭据")
}
// Anthropic / OpenAI 客户端要带的头必须在允许列表里,否则浏览器预检直接失败
// (头名大小写不敏感,浏览器实际发的是小写 x-api-key)
ah := strings.ToLower(h.Get("Access-Control-Allow-Headers"))
if !strings.Contains(ah, "x-api-key") || !strings.Contains(ah, "anthropic-version") {
t.Fatalf("允许的请求头缺失:%s", ah)
}
if h := get("http://evil.com"); h.Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("白名单外不应发任何 CORS 头,实际 %q", h.Get("Access-Control-Allow-Origin"))
}
if h := get(""); h.Get("Access-Control-Allow-Origin") != "" {
t.Fatal("同源请求(无 Origin 头)不该带 CORS 头")
}
}
func TestWildcardNeverCarriesCredentials(t *testing.T) {
srv := newServer([]string{"*"})
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil)
req.Header.Set("Origin", "http://anywhere.test")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("通配应回 *,实际 %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("通配模式下不能允许凭据,实际 %q", got)
}
}
func TestEmptyWhitelistDisablesCORS(t *testing.T) {
srv := newServer(nil)
defer srv.Close()
req, _ := http.NewRequest(http.MethodOptions, srv.URL+"/ping", nil)
req.Header.Set("Origin", "http://evil.com")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("预检应短路为 204,实际 %d", resp.StatusCode)
}
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
t.Fatal("空白名单不应发 CORS 头")
}
}
func TestBodyLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(BodyLimit(16))
var readErr error
r.POST("/echo", func(c *gin.Context) {
_, readErr = io.ReadAll(c.Request.Body)
c.Status(http.StatusOK)
})
srv := httptest.NewServer(r)
defer srv.Close()
// 未超限:正常读到
if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader("tiny")); err != nil {
t.Fatal(err)
}
if readErr != nil {
t.Fatalf("小 body 读取失败:%v", readErr)
}
// 超限:读的时候拿到 MaxBytesError(代理层据此回 413)
if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader(strings.Repeat("x", 64))); err != nil {
t.Fatal(err)
}
var mb *http.MaxBytesError
if !errors.As(readErr, &mb) {
t.Fatalf("应报 MaxBytesError,实际 %T: %v", readErr, readErr)
}
}
+39 -6
View File
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
@@ -59,16 +60,37 @@ func AdminOnly(c *gin.Context) {
c.Next()
}
// CORS 开发期放开;生产按配置白名单(PLANNING §8)。
func CORS() gin.HandlerFunc {
// CORS 按显式 origin 白名单放行跨域;allow 为空则完全不输出 CORS 头。
//
// 同源部署(网关自己托管 web/dist)和 vite dev 的 proxy 都不需要 CORS,所以默认关是安全的。
// 之前无条件反射 Origin 并带 Allow-Credentials: true,唯一挡住跨站凭据读取的是
// refresh cookie 的 SameSite=Lax——那是巧合而非防线,一旦有人为了嵌 iframe 把它改成
// None,任意站点就能带着 cookie 调 /auth/refresh 读回新 token。
func CORS(allow []string) gin.HandlerFunc {
allowed := make(map[string]bool, len(allow))
wildcard := false
for _, o := range allow {
if o == "*" {
wildcard = true
continue
}
allowed[strings.TrimRight(o, "/")] = true
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
if origin != "" && (wildcard || allowed[strings.TrimRight(origin, "/")]) {
if wildcard {
// 通配不配凭据:/v1 靠 Authorization / x-api-key 头认证,不吃 cookie
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
}
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
c.Header("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Requested-With, X-Api-Key, Anthropic-Version, OpenAI-Beta, OpenAI-Organization, OpenAI-Project")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Max-Age", "600")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
@@ -77,3 +99,14 @@ func CORS() gin.HandlerFunc {
c.Next()
}
}
// BodyLimit 限制请求体字节数。超限后 ReadAll 会拿到 *http.MaxBytesError,
// 由代理层按客户端协议返回 413;0 表示不限制。
func BodyLimit(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
if maxBytes > 0 {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
}
c.Next()
}
}
+9 -2
View File
@@ -19,12 +19,19 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
// 只有显式配置了反代列表才信任 X-Forwarded-For;否则 ClientIP 退回 RemoteAddr,
// 避免伪造头把认证限速绕开。
if len(a.Cfg.TrustedProxies) > 0 {
if err := r.SetTrustedProxies(a.Cfg.TrustedProxies); err != nil {
panic("invalid trusted proxies: " + err.Error())
}
}
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins))
h := NewHandler(a, gw, a.Passkeys)
// --- 代理端点(对外)---
proxyGroup := r.Group("/v1")
proxyGroup := r.Group("/v1", middleware.BodyLimit(int64(a.Cfg.Proxy.MaxBodyMB)*1024*1024))
{
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
+2 -1
View File
@@ -31,7 +31,8 @@ func serve() error {
}
defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw, cfg.Proxy.Timeout)
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw,
cfg.Proxy.Timeout, cfg.Proxy.MaxBodyMB)
router := api.NewRouter(a, gw)
srv := &http.Server{
+79 -4
View File
@@ -3,6 +3,7 @@
package config
import (
"fmt"
"os"
"strings"
"time"
@@ -20,6 +21,15 @@ type Config struct {
RateLimit RateLimitConfig
WebAuthn WebAuthnConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
// 管理员初始密码:仅用于生产启动断言,Seed 仍按原样直接读环境变量
AdminPassword string
// 跨域白名单(逗号分隔 origin)。为空 = 不输出任何 CORS 头:
// 网关自己托管 web/dist 时前后端同源,本就不需要 CORS;开发用 vite proxy 也不需要。
CORSAllowOrigins []string
// 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP,
// 认证限速依赖这个 IP;直连部署留空即可。
TrustedProxies []string
}
// WebAuthnConfig Passkey(WebAuthn)配置。
@@ -32,6 +42,10 @@ type WebAuthnConfig struct {
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
type RateLimitConfig struct {
UserRPS int // 用户级每秒请求数上限(0=不限制)
// 认证端点限速(每分钟):登录是密码爆破面,注册是批量建号面。0=不限制。
LoginIPPerMin int // 单 IP 登录尝试数
LoginUserPerMin int // 单 IP 对同一账号的登录尝试数(挡住换 IP 定向爆破)
RegisterIPPerMin int // 单 IP 注册次数
}
type DBConfig struct {
@@ -64,6 +78,7 @@ type ProxyConfig struct {
UpstreamKey string // 渠道上游 key 默认值
DefaultModel string // 渠道模型导入时使用的模型名
Timeout time.Duration
MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413
HealthInterval time.Duration // 渠道健康检查周期
HealthFailThreshold int // 连续失败 N 次进 cooldown
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
@@ -128,17 +143,24 @@ func Load() (*Config, error) {
v.SetDefault("proxy.upstream_key", "")
v.SetDefault("proxy.default_model", "gpt-4o-mini")
v.SetDefault("proxy.timeout", "300s")
v.SetDefault("proxy.max_body_mb", 32)
v.SetDefault("proxy.health_interval", "60s")
v.SetDefault("proxy.health_fail_threshold", 2)
v.SetDefault("proxy.log_raw", false)
v.SetDefault("ratelimit.user_rps", 20)
v.SetDefault("ratelimit.login_ip_per_min", 30)
v.SetDefault("ratelimit.login_user_per_min", 10)
v.SetDefault("ratelimit.register_ip_per_min", 5)
v.SetDefault("cors.allow_origins", "")
v.SetDefault("server.trusted_proxies", "")
v.SetDefault("webauthn.rp_id", "localhost")
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
v.SetDefault("webauthn.rp_name", "openteam")
return &Config{
cfg := &Config{
Env: v.GetString("env"),
Port: v.GetInt("port"),
DB: DBConfig{
@@ -168,18 +190,71 @@ func Load() (*Config, error) {
UpstreamKey: v.GetString("proxy.upstream_key"),
DefaultModel: v.GetString("proxy.default_model"),
Timeout: v.GetDuration("proxy.timeout"),
MaxBodyMB: v.GetInt("proxy.max_body_mb"),
HealthInterval: v.GetDuration("proxy.health_interval"),
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
LogRaw: v.GetBool("proxy.log_raw"),
},
RateLimit: RateLimitConfig{
UserRPS: v.GetInt("ratelimit.user_rps"),
UserRPS: v.GetInt("ratelimit.user_rps"),
LoginIPPerMin: v.GetInt("ratelimit.login_ip_per_min"),
LoginUserPerMin: v.GetInt("ratelimit.login_user_per_min"),
RegisterIPPerMin: v.GetInt("ratelimit.register_ip_per_min"),
},
WebAuthn: WebAuthnConfig{
RPID: v.GetString("webauthn.rp_id"),
RPOrigin: v.GetString("webauthn.rp_origin"),
RPName: v.GetString("webauthn.rp_name"),
},
Master: v.GetString("master_key"),
}, nil
Master: v.GetString("master_key"),
AdminPassword: v.GetString("admin_password"),
CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")),
TrustedProxies: splitList(v.GetString("server.trusted_proxies")),
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// splitList 解析逗号分隔列表(去空白、忽略空项)。
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// validate 生产环境的密钥断言:这些默认值是公开已知的,忘配就等于没有认证。
//
// master key 缺失时 crypto.NewEncryptor 会用 sha256("") 派生密钥(结果同样公开),
// 渠道上游密钥的密文就变成谁都能解;jwt.secret 用默认值则能直接伪造管理员 token。
func (c *Config) validate() error {
if c.Env != "production" {
return nil
}
var problems []string
if c.JWT.Secret == "" || c.JWT.Secret == "dev-only-secret-change-me" {
problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)")
}
if len(c.Master) < 16 {
problems = append(problems, "OT_MASTER_KEY 未设置或少于 16 字节(缺失时会用 sha256(\"\") 派生,渠道密文可被公开还原)")
}
if c.AdminPassword == "" || c.AdminPassword == "admin123" {
problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123")
}
if len(problems) > 0 {
return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";"))
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"strings"
"testing"
)
func prodCfg() *Config {
c := &Config{Env: "production"}
c.JWT.Secret = "a-real-random-secret-not-the-default"
c.Master = "0123456789abcdef0123456789abcdef"
c.AdminPassword = "strong-enough-password"
return c
}
func TestValidateProduction(t *testing.T) {
if err := prodCfg().validate(); err != nil {
t.Fatalf("配齐密钥应通过:%v", err)
}
cases := []struct {
name string
mutate func(*Config)
want string
}{
{"默认 jwt secret", func(c *Config) { c.JWT.Secret = "dev-only-secret-change-me" }, "OT_JWT_SECRET"},
{"空 jwt secret", func(c *Config) { c.JWT.Secret = "" }, "OT_JWT_SECRET"},
{"缺失 master key", func(c *Config) { c.Master = "" }, "OT_MASTER_KEY"},
{"过短 master key", func(c *Config) { c.Master = "short" }, "OT_MASTER_KEY"},
{"默认管理员密码", func(c *Config) { c.AdminPassword = "admin123" }, "OT_ADMIN_PASSWORD"},
{"未设管理员密码", func(c *Config) { c.AdminPassword = "" }, "OT_ADMIN_PASSWORD"},
}
for _, tc := range cases {
c := prodCfg()
tc.mutate(c)
err := c.validate()
if err == nil {
t.Fatalf("%s:应被拒绝", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s:报错应点名 %s,实际 %v", tc.name, tc.want, err)
}
}
// 多项同时缺失要一次全说清,别让人改一个试一个
c := prodCfg()
c.JWT.Secret = ""
c.Master = ""
err := c.validate()
if err == nil || !strings.Contains(err.Error(), "OT_JWT_SECRET") || !strings.Contains(err.Error(), "OT_MASTER_KEY") {
t.Fatalf("应一次列出全部问题:%v", err)
}
// 开发环境不拦(本地跑默认值就是设计如此)
dev := prodCfg()
dev.Env = "development"
dev.JWT.Secret = "dev-only-secret-change-me"
dev.Master = ""
dev.AdminPassword = "admin123"
if err := dev.validate(); err != nil {
t.Fatalf("development 不应拦截:%v", err)
}
}
func TestSplitList(t *testing.T) {
if got := splitList(""); got != nil {
t.Fatalf("空串应为 nil,实际 %v", got)
}
if got := splitList(" , ,, "); got != nil {
t.Fatalf("纯分隔符应为 nil,实际 %v", got)
}
got := splitList("https://a.com, https://b.com ,,http://c.com")
if len(got) != 3 || got[0] != "https://a.com" || got[2] != "http://c.com" {
t.Fatalf("解析错误:%v", got)
}
}
@@ -24,6 +24,7 @@ type Limiter struct {
reqDaily map[uint64]*dayCounter // 密钥每日请求数
tokDaily map[uint64]*dayCounter // 密钥每日 token 用量
userHits map[uint64]*hitWindow // 用户速率窗口
authHits map[string]*hitWindow // 认证端点速率窗口(按 IP / IP+账号)
}
func New() *Limiter {
@@ -31,6 +32,7 @@ func New() *Limiter {
reqDaily: map[uint64]*dayCounter{},
tokDaily: map[uint64]*dayCounter{},
userHits: map[uint64]*hitWindow{},
authHits: map[string]*hitWindow{},
}
}
@@ -110,3 +112,52 @@ func (l *Limiter) AllowUserRate(userID uint64, limit int) bool {
w.times = append(w.times, now)
return true
}
// AllowWindow 字符串键滑动窗口限速;limit<=0 表示不限制,返回 false 表示已超限。
// 用于认证端点(登录爆破、批量注册),键由调用方拼出(IP、IP+用户名等)。
// 窗口内已无命中时删除条目,避免陌生键把 map 撑大。
func (l *Limiter) AllowWindow(key string, limit int, window time.Duration) bool {
if limit <= 0 {
return true
}
if window <= 0 {
window = time.Minute
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
w, ok := l.authHits[key]
if !ok {
w = &hitWindow{limit: limit, window: window}
l.authHits[key] = w
}
cutoff := now.Add(-w.window)
keep := w.times[:0]
for _, t := range w.times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
w.times = keep
if len(w.times) >= w.limit {
return false
}
w.times = append(w.times, now)
// 键由外部拼出(IP 等),条目只增不减会长期堆积:规模到阈值就整体扫一遍过期键
if len(l.authHits) > 4096 {
for k, v := range l.authHits {
vcut := now.Add(-v.window)
alive := v.times[:0]
for _, t := range v.times {
if t.After(vcut) {
alive = append(alive, t)
}
}
v.times = alive
if len(v.times) == 0 {
delete(l.authHits, k)
}
}
}
return true
}
@@ -0,0 +1,39 @@
package ratelimit
import (
"testing"
"time"
)
func TestAllowWindow(t *testing.T) {
l := New()
const win = 80 * time.Millisecond
// 窗口内到量就拒
for i := 0; i < 3; i++ {
if !l.AllowWindow("login:ip:1.2.3.4", 3, win) {
t.Fatalf("第 %d 次应放行", i+1)
}
}
if l.AllowWindow("login:ip:1.2.3.4", 3, win) {
t.Fatal("超过限额应拒绝")
}
// 不同键互不影响(换 IP 不会被同一个桶牵连)
if !l.AllowWindow("login:ip:5.6.7.8", 3, win) {
t.Fatal("另一个 IP 应独立计数")
}
if !l.AllowWindow("login:u:1.2.3.4|admin", 3, win) {
t.Fatal("同一 IP 的另一个账号桶应独立")
}
// 窗口滑过即恢复
time.Sleep(120 * time.Millisecond)
if !l.AllowWindow("login:ip:1.2.3.4", 3, win) {
t.Fatal("窗口过期后应重新放行")
}
// limit<=0 不限制
for i := 0; i < 5; i++ {
if !l.AllowWindow("free", 0, win) {
t.Fatal("limit<=0 应始终放行")
}
}
}
+3 -1
View File
@@ -41,6 +41,7 @@ type Gateway struct {
// 上游超时兜底值(渠道 timeout_ms 非法时使用);按超时值缓存 Transport 复用连接池。
gwTimeout time.Duration
maxBodyMB int // /v1 请求体上限(MB),仅用于超限报错文案
tpMu sync.Mutex
tps map[time.Duration]*http.Transport
@@ -131,7 +132,7 @@ func contains(list []string, s string) bool {
return false
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration) *Gateway {
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration, maxBodyMB int) *Gateway {
return &Gateway{
db: db,
ch: channel.NewService(db, enc),
@@ -141,6 +142,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
userRPS: userRPS,
logRaw: logRaw,
gwTimeout: gw_timeout,
maxBodyMB: maxBodyMB,
tps: map[time.Duration]*http.Transport{},
}
}
+16 -3
View File
@@ -1,6 +1,8 @@
package proxy
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -8,6 +10,17 @@ import (
"github.com/openteam/server/internal/store"
)
// failBodyRead 读取请求体失败时的报错:超限单独回 413,让客户端能区分"体太大"和"体畸形"。
func (g *Gateway) failBodyRead(c *gin.Context, err error) {
var mb *http.MaxBytesError
if errors.As(err, &mb) {
apiError(c, http.StatusRequestEntityTooLarge, "request_too_large",
fmt.Sprintf("请求体超过网关上限 %d MB", g.maxBodyMB))
return
}
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
}
// chatCompletions POST /v1/chat/completions
func (g *Gateway) chatCompletions(c *gin.Context) {
u, ok := g.resolveUser(c)
@@ -19,7 +32,7 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
}
br, body, err := parseBody(c)
if err != nil {
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoChat)
@@ -52,7 +65,7 @@ func (g *Gateway) responses(c *gin.Context) {
}
br, body, err := parseBody(c)
if err != nil {
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoResponses)
@@ -85,7 +98,7 @@ func (g *Gateway) messages(c *gin.Context) {
}
br, body, err := parseBody(c)
if err != nil {
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoMessages)
+30 -2
View File
@@ -76,7 +76,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
rec := usage.NewRecorder(db)
t.Cleanup(rec.Close)
return &harness{
gw: NewGateway(db, enc, rec, ratelimit.New(), 0, false, 300*time.Second),
gw: NewGateway(db, enc, rec, ratelimit.New(), 0, false, 300*time.Second, 32),
db: db, rec: rec, key: plain,
}
}
@@ -294,7 +294,7 @@ func TestRuntimeFailureCounterResetsOnSuccess(t *testing.T) {
// 回归:全局兜底超时值不再被二次换算。
func TestGatewayTimeoutIsNotMultiplied(t *testing.T) {
gw := NewGateway(nil, nil, nil, nil, 0, false, 30*time.Second)
gw := NewGateway(nil, nil, nil, nil, 0, false, 30*time.Second, 8)
if got := gw.channelTimeout(&store.Channel{TimeoutMS: 0}); got != 30*time.Second {
t.Fatalf("兜底超时 = %s,期望 30s", got)
}
@@ -305,3 +305,31 @@ func TestGatewayTimeoutIsNotMultiplied(t *testing.T) {
t.Fatalf("client.Timeout 应为 0(由 context 控制整段时长),实际 %s", c.Timeout)
}
}
// 请求体超过上限时应按客户端协议返回 413(上限由 /v1 组的 BodyLimit 中间件把住)。
func TestRequestBodyTooLarge(t *testing.T) {
h := newHarness(t, "http://127.0.0.1:1", 0, true, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
strings.NewReader(`{"model":"m1","messages":[{"role":"user","content":"`+strings.Repeat("x", 4096)+`"}]}`))
req.Header.Set("Authorization", "Bearer "+h.key)
// 模拟 middleware.BodyLimit:超限后 ReadAll 拿到 *http.MaxBytesError
req.Body = http.MaxBytesReader(w, req.Body, 64)
c.Request = req
h.gw.Auth(c)
if !c.IsAborted() {
h.gw.Handle(c)
}
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("应返回 413,实际 %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "request_too_large") {
t.Fatalf("错误码不符:%s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "32 MB") {
t.Fatalf("报错应点名上限,实际:%s", w.Body.String())
}
}