ac38d5e663
部署前生产级审计(可靠性/数据层/安全三路)后,清掉 7 处代码级硬伤: A1 后台定时器 goroutine 无 panic recover → 单个 DB panic 崩整个 gateway。加 safeGo/ safeCall,包住订阅/掉单补偿/微信推送/探针 goroutine,单轮 tick 再兜一层。 A2 提示词控制面(建/激活/停用,热广播全服务)只 RequireAuth → 任意登录用户改全局提示词。 三写端点+列表挂 RequireAdmin。 A3 HITL 审批端点无角色门 → viewer 可放行烧钱执行。加 RequireTenantRole(member)。 A4 审计/护栏列表 limit 无校验,limit=-1 让 gorm 取消 LIMIT 全表扫。加 clampLimit/ clampOffset,AdminTasks/AdminSpaces 补上界。 A5 限流 Redis 一挂就完全放行(fail-open)。加进程内固定窗口兜底(fail-safe) + 登录/注册 按 IP 专用严限流(10/min)。 A6 公开 by-id 端点(stream/exec/report导出/kb导入流)无鉴权无租户过滤。加 AuthFromHeaderOrQuery(从 ?token= 取 JWT) + task/report 按 owner 归属校验;桌面端 5 处 EventSource/下载 URL 经 tokenQuery 附 JWT。 A7 文件上传无大小上限(整文件进内存 OOM 面) → 50MB 闸(KB_MAX_UPLOAD_BYTES)+ LimitReader; http.Server 加 ReadHeaderTimeout/ReadTimeout/MaxHeaderBytes(不设 WriteTimeout 保 SSE)。 带单测:clampLimit/safeCall/procLimiter/AuthFromHeaderOrQuery/TaskOwner。 build+vet+全量 test 绿;desktop tsc 绿。B(迁移工具/实时探针/出网韧性/登录锁定/leader选举) 与 C(TLS/PG HA/K8s/备份自动化/可观测)分期后做,参照 production_readiness.md。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
3.5 KiB
Go
91 lines
3.5 KiB
Go
// 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()
|
||
}
|
||
}
|