Files
Blizzard 23b8fa5e8a 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>
2026-07-02 13:55:20 +08:00

92 lines
3.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 降级时始终放行,不阻断业务。
func RateLimit(cache *store.Redis) gin.HandlerFunc {
perMinute := int64(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
}
}
ok, _ := cache.Allow(c.Request.Context(), key, perMinute, time.Minute)
if !ok {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}