// Package middleware 提供 Guardrail 与限流等接入层中间件。 package middleware import ( "bytes" "context" "encoding/json" "io" "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/sundynix/sundynix-gateway/internal/guardrail" "github.com/sundynix/sundynix-gateway/internal/store" ) // Guardrail 实现 Harness 输入护栏:拦截提示词注入 / 超大请求体。 // 只检查带 JSON 体的写请求(POST/PUT);文件上传(multipart)与 GET/SSE 不经此。 // 输出护栏不在此做 —— Token 流为 SSE 实时流,网关缓冲会破坏流式,输出过滤应在 // dispatcher 的 token 发射层(见 PROGRESS 路线图)。 // 命中(blocked/suspect)除打日志外,best-effort 落库 guardrail_event 供安全溯源。 func Guardrail(db *store.Postgres) gin.HandlerFunc { return func(c *gin.Context) { if m := c.Request.Method; (m == http.MethodPost || m == http.MethodPut) && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { // 限读上限 + 1 字节以判定"过大";命中拦截则后续 handler 不执行。 body, _ := io.ReadAll(io.LimitReader(c.Request.Body, guardrail.MaxJSONBytes+1)) res := guardrail.Inspect(body) if res.Blocked { log.Printf("[guardrail] 拦截 %s %s:%s", c.Request.Method, c.Request.URL.Path, res.Reason) recordGuardrail(c, db, "blocked", res.Reason, res.Signals) c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "输入护栏拦截:" + res.Reason}) return } if res.Suspect { // 灰区:放行但打标,交 Dispatcher 的 LLM 分类器(Tier2)裁决 log.Printf("[guardrail] 灰区放行 %s %s:软信号 %v", c.Request.Method, c.Request.URL.Path, res.Signals) c.Set("guardrail_suspect", true) recordGuardrail(c, db, "suspect", res.Reason, res.Signals) } c.Request.Body = io.NopCloser(bytes.NewReader(body)) // 还原请求体供后续 handler 读取 } c.Next() } } // recordGuardrail best-effort 落库一条护栏事件(独立超时 ctx,失败静默)。 func recordGuardrail(c *gin.Context, db *store.Postgres, kind, reason string, signals []string) { if db == nil { return } uid, _ := c.Get(CtxUserID) actor, _ := uid.(string) sig := "" if len(signals) > 0 { if b, err := json.Marshal(signals); err == nil { sig = string(b) } } e := &store.GuardrailEvent{ Actor: actor, Kind: kind, Reason: reason, Signals: sig, Method: c.Request.Method, Path: c.Request.URL.Path, IP: c.ClientIP(), } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = db.AppendGuardrailEvent(ctx, e) } // RateLimit 基于 Redis 的会话级限流(每分钟上限)。 // 限流键:**已认证用户优先按 uid,未认证按客户端 IP** —— 企业网多人共享出口 IP 不再互相拖累, // 单用户换 IP 也绕不过。须挂在 Auth 之后(否则取不到 uid)。上限经 RATE_LIMIT_PER_MIN 配置 // (缺省 120);压测可调高。**Redis 降级时回落进程内兜底限流(fail-safe),不再完全放行。** func RateLimit(cache *store.Redis) gin.HandlerFunc { perMinute := envInt("RATE_LIMIT_PER_MIN", 120) return func(c *gin.Context) { key := "ip:" + c.ClientIP() if v, ok := c.Get(CtxUserID); ok { if uid, _ := v.(string); uid != "" { key = "u:" + uid } } if !allowWithFallback(cache, c.Request.Context(), key, perMinute) { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) return } c.Next() } }