From 23b8fa5e8acb050c0670b40fc788129f693b437c Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 2 Jul 2026 13:55:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E5=AE=89=E5=85=A8=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20=E2=80=94=E2=80=94=20CORS=20=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E6=94=B6=E7=B4=A7=20+=20=E9=99=90=E6=B5=81=E6=8C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=EF=BC=88T4.F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- DEPTH_ROADMAP.md | 5 ++- .../internal/middleware/guardrail.go | 14 +++++-- sundynix-gateway/internal/router/router.go | 40 +++++++++++++------ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/DEPTH_ROADMAP.md b/DEPTH_ROADMAP.md index a042022..57d6101 100644 --- a/DEPTH_ROADMAP.md +++ b/DEPTH_ROADMAP.md @@ -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 diff --git a/sundynix-gateway/internal/middleware/guardrail.go b/sundynix-gateway/internal/middleware/guardrail.go index 14ddf73..70b5de7 100644 --- a/sundynix-gateway/internal/middleware/guardrail.go +++ b/sundynix-gateway/internal/middleware/guardrail.go @@ -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 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 1063742..0c52bf9 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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") +}