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:
1 parent
d4c5263f1e
commit
ad10bdc400
14 files changed
+521
-28
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 应始终放行")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user