feat(gateway): 安全收口 —— CORS 生产收紧 + 限流按用户(T4.F)

- CORS:开发期缺省仍放行 *(便利);生产(APP_ENV=prod/GIN_MODE=release)未显式配
  CORS_ALLOW_ORIGIN 则不发 ACAO 头(浏览器按同源拦截),逼运维显式配置允许的源
- 限流键改「已认证按 uid、未认证按 IP」:企业网多人共享出口 IP 不再互相拖累,
  单用户换 IP 也绕不过;中间件顺序调整 Auth 前置于 RateLimit(否则取不到 uid)
- isProd() 判定与 middleware.RequireAdmin 同口径
- live 冒烟:登录/认证请求正常(重排未破链),dev CORS 仍 *

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-02 13:55:20 +08:00
parent 6095bc71d2
commit 23b8fa5e8a
3 changed files with 43 additions and 16 deletions
+4 -1
View File
@@ -196,7 +196,10 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
- [ ] KB 级联删事务化(kb.go:172 三库删一半失败仍删 PG → 不一致)| M
- [ ] 预算硬顶兜底(budget.go ≤0 即无限,单任务可无限烧)| M
- [ ] 审批 checkpoint 落盘失败重试(compose_compiler.go:323 现只 log → 任务永卡 waiting| M
- [ ] 安全:CORS 缺省去 `*`、限流按登录用户(非纯 IP)、`X-Session-ID` 防伪、开发 JWT 密钥外置、输入校验补强 | S×N
- [~] 安全收口(部分):
- [x] CORS 生产默认收紧 ✅ —— 开发期缺省仍 `*`(便利);生产(APP_ENV=prod/GIN_MODE=release)未显式配 CORS_ALLOW_ORIGIN 则**不发 ACAO 头**(浏览器同源拦截),逼显式配置。
- [x] 限流按登录用户 ✅ —— RateLimit 键改「已认证按 uid、未认证按 IP」;middleware 顺序 Auth 前置于 RateLimit。live 冒烟通过(登录/认证请求正常,dev CORS 仍 *)。
- [ ] `X-Session-ID` 防伪 / 开发 JWT 密钥外置 / 输入校验补强 | S(待)
- [ ] 性能:ListModels 去 O(N²)admin.go:48)、KB/Agent 列表分页、报告分章检索缓存、Milvus 批量 ensure | S~M
- [ ] 可观测:panic 进 trace span + 回写通知、TTFT/token-s/各路检索耗时指标 | M
- [ ] 配置化:切块大小 / history 轮数 / 各并发度 收口为统一可配 | S
@@ -68,12 +68,20 @@ func recordGuardrail(c *gin.Context, db *store.Postgres, kind, reason string, si
_ = db.AppendGuardrailEvent(ctx, e)
}
// RateLimit 基于 Redis 的会话级限流(按客户端 IP每分钟上限)。
// 上限经 RATE_LIMIT_PER_MIN 配置(缺省 120);压测可调高。Redis 降级时始终放行,不阻断业务。
// RateLimit 基于 Redis 的会话级限流(每分钟上限)。
// 限流键:**已认证用户优先按 uid,未认证按客户端 IP** —— 企业网多人共享出口 IP 不再互相拖累,
// 单用户换 IP 也绕不过。须挂在 Auth 之后(否则取不到 uid)。上限经 RATE_LIMIT_PER_MIN 配置
// (缺省 120);压测可调高。Redis 降级时始终放行,不阻断业务。
func RateLimit(cache *store.Redis) gin.HandlerFunc {
perMinute := int64(envInt("RATE_LIMIT_PER_MIN", 120))
return func(c *gin.Context) {
ok, _ := cache.Allow(c.Request.Context(), c.ClientIP(), perMinute, time.Minute)
key := "ip:" + c.ClientIP()
if v, ok := c.Get(CtxUserID); ok {
if uid, _ := v.(string); uid != "" {
key = "u:" + uid
}
}
ok, _ := cache.Allow(c.Request.Context(), key, perMinute, time.Minute)
if !ok {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
+28 -12
View File
@@ -2,7 +2,9 @@
package router
import (
"log"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -22,10 +24,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
r.Use(cors()) // 桌面端/浏览器跨源访问
r.Use(middleware.RateLimit(cache))
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event
r.Use(cors()) // 桌面端/浏览器跨源访问
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event
h := handler.New(db, cache, bus, blobStore)
@@ -103,17 +105,24 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
return r
}
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置(缺省 "*" 仅供开发;
// 生产应设为具体源,如 https://app.example.com)。Vary 保证按 Origin 正确缓存。
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置
// 安全默认:开发期缺省放行 "*"(便利);生产期(APP_ENV=prod / GIN_MODE=release)未显式配置则
// **不放行任意源**(不发 ACAO 头 → 浏览器按同源策略拦截),逼运维显式配置允许的源。
func cors() gin.HandlerFunc {
origin := "*"
if v := os.Getenv("CORS_ALLOW_ORIGIN"); v != "" {
origin = v
origin := os.Getenv("CORS_ALLOW_ORIGIN")
if origin == "" {
if isProd() {
log.Printf("[cors] 生产环境未设 CORS_ALLOW_ORIGIN:默认不放行跨源,请显式配置允许的源(如 https://app.example.com")
} else {
origin = "*" // 开发便利
}
}
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", origin)
if origin != "*" {
c.Header("Vary", "Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
if origin != "*" {
c.Header("Vary", "Origin")
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
@@ -124,3 +133,10 @@ func cors() gin.HandlerFunc {
c.Next()
}
}
// isProd 判定生产环境(与 middleware.RequireAdmin 同口径)。
func isProd() bool {
return strings.EqualFold(os.Getenv("APP_ENV"), "production") ||
strings.EqualFold(os.Getenv("APP_ENV"), "prod") ||
strings.EqualFold(os.Getenv("GIN_MODE"), "release")
}