9501751792
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
>4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
438 lines
13 KiB
Go
438 lines
13 KiB
Go
package contentpolicy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrBodyTooLarge = errors.New("request body is too large")
|
|
|
|
type Rule struct {
|
|
Name string `json:"name"`
|
|
Pattern string `json:"pattern"`
|
|
Replacement string `json:"replacement"`
|
|
}
|
|
|
|
type Policy struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Action string `json:"action"`
|
|
Priority int `json:"priority"`
|
|
Paths []string `json:"paths"`
|
|
Models []string `json:"models"`
|
|
APIKeyIDs []string `json:"api_key_ids"`
|
|
Rules []Rule `json:"rules"`
|
|
Enabled bool `json:"enabled"`
|
|
Revision int64 `json:"revision"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type compiledRule struct {
|
|
name, replacement string
|
|
expression *regexp.Regexp
|
|
}
|
|
type compiledPolicy struct {
|
|
Policy
|
|
rules []compiledRule
|
|
}
|
|
type snapshot struct{ policies []compiledPolicy }
|
|
|
|
type Engine struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
refresh time.Duration
|
|
current atomic.Pointer[snapshot]
|
|
}
|
|
|
|
type Match struct {
|
|
PolicyID string `json:"policy_id"`
|
|
PolicyName string `json:"policy_name"`
|
|
Action string `json:"action"`
|
|
Rules []string `json:"rules"`
|
|
}
|
|
|
|
type Result struct {
|
|
Matches []Match
|
|
Blocked, Redacted bool
|
|
}
|
|
|
|
func NewEngine(pool *pgxpool.Pool, refresh time.Duration, logger *slog.Logger) *Engine {
|
|
if refresh <= 0 {
|
|
refresh = 30 * time.Second
|
|
}
|
|
e := &Engine{pool: pool, refresh: refresh, logger: logger}
|
|
e.current.Store(&snapshot{})
|
|
return e
|
|
}
|
|
|
|
func (e *Engine) Run(ctx context.Context) {
|
|
_ = e.Reload(ctx)
|
|
ticker := time.NewTicker(e.refresh)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := e.Reload(ctx); err != nil && e.logger != nil {
|
|
e.logger.Warn("content policy refresh failed", "error", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Engine) Reload(ctx context.Context) error {
|
|
if e == nil || e.pool == nil {
|
|
return errors.New("content policy store unavailable")
|
|
}
|
|
rows, err := e.pool.Query(ctx, `SELECT id::text,name,description,action,priority,paths,models,api_key_ids::text[],rules,enabled,revision,created_at,updated_at FROM gateway.content_policies WHERE enabled ORDER BY priority DESC,name,id`)
|
|
if err != nil {
|
|
return fmt.Errorf("load content policies: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
loaded := make([]compiledPolicy, 0)
|
|
for rows.Next() {
|
|
var policy Policy
|
|
var raw []byte
|
|
if err := rows.Scan(&policy.ID, &policy.Name, &policy.Description, &policy.Action, &policy.Priority, &policy.Paths, &policy.Models, &policy.APIKeyIDs, &raw, &policy.Enabled, &policy.Revision, &policy.CreatedAt, &policy.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(raw, &policy.Rules); err != nil {
|
|
return fmt.Errorf("decode policy %s: %w", policy.ID, err)
|
|
}
|
|
compiled, err := compilePolicy(policy)
|
|
if err != nil {
|
|
return fmt.Errorf("compile policy %s: %w", policy.ID, err)
|
|
}
|
|
loaded = append(loaded, compiled)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
e.current.Store(&snapshot{policies: loaded})
|
|
return nil
|
|
}
|
|
|
|
func compilePolicy(policy Policy) (compiledPolicy, error) {
|
|
if policy.Action != "audit" && policy.Action != "block" && policy.Action != "redact" {
|
|
return compiledPolicy{}, errors.New("unsupported action")
|
|
}
|
|
if len(policy.Rules) == 0 || len(policy.Rules) > 20 {
|
|
return compiledPolicy{}, errors.New("rules must contain 1 to 20 entries")
|
|
}
|
|
result := compiledPolicy{Policy: policy, rules: make([]compiledRule, 0, len(policy.Rules))}
|
|
for _, rule := range policy.Rules {
|
|
if strings.TrimSpace(rule.Name) == "" || len(rule.Pattern) == 0 || len(rule.Pattern) > 512 {
|
|
return compiledPolicy{}, errors.New("invalid rule name or pattern length")
|
|
}
|
|
expression, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return compiledPolicy{}, err
|
|
}
|
|
replacement := rule.Replacement
|
|
if replacement == "" {
|
|
replacement = "[REDACTED]"
|
|
}
|
|
result.rules = append(result.rules, compiledRule{name: rule.Name, replacement: replacement, expression: expression})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func Validate(policy Policy) error { _, err := compilePolicy(policy); return err }
|
|
|
|
func (e *Engine) Apply(request *http.Request, maxBody int64, apiKeyID string) (Result, error) {
|
|
if e == nil || request.Body == nil || request.Body == http.NoBody || request.Method == http.MethodGet {
|
|
return Result{}, nil
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(request.Body, maxBody+1))
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
_ = request.Body.Close()
|
|
if int64(len(body)) > maxBody {
|
|
return Result{}, ErrBodyTooLarge
|
|
}
|
|
restoreBody(request, body)
|
|
var document any
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.UseNumber()
|
|
if decoder.Decode(&document) != nil {
|
|
return Result{}, nil
|
|
}
|
|
model := findModel(document)
|
|
result := Result{}
|
|
// 只收集"本请求命中"的 redact 策略的规则,供字节级重写使用;
|
|
// 跨端点/模型/API Key 作用域的策略不得改写本请求。
|
|
matchedRedactionRules := make([]compiledRule, 0)
|
|
for _, policy := range e.current.Load().policies {
|
|
if !policyApplies(policy, request.URL.Path, model, apiKeyID) {
|
|
continue
|
|
}
|
|
matched := make([]string, 0)
|
|
changed := false
|
|
walkText(&document, false, func(value string) string {
|
|
for _, rule := range policy.rules {
|
|
if rule.expression.MatchString(value) {
|
|
if !slices.Contains(matched, rule.name) {
|
|
matched = append(matched, rule.name)
|
|
}
|
|
if policy.Action == "redact" {
|
|
next := rule.expression.ReplaceAllString(value, rule.replacement)
|
|
changed = changed || next != value
|
|
value = next
|
|
}
|
|
}
|
|
}
|
|
return value
|
|
})
|
|
if len(matched) == 0 {
|
|
continue
|
|
}
|
|
result.Matches = append(result.Matches, Match{PolicyID: policy.ID, PolicyName: policy.Name, Action: policy.Action, Rules: matched})
|
|
if policy.Action == "block" {
|
|
result.Blocked = true
|
|
break
|
|
}
|
|
result.Redacted = result.Redacted || changed
|
|
if policy.Action == "redact" && changed {
|
|
matchedRedactionRules = append(matchedRedactionRules, policy.rules...)
|
|
}
|
|
}
|
|
if result.Redacted && !result.Blocked {
|
|
// 在原始请求字节上做字符串字面量级替换,而不是解码后重新
|
|
// json.Marshal:重编码会改变键序、数字格式与 HTML 转义,破坏上游
|
|
// 的请求签名/哈希与字节级契约,且对大 body 是双倍编解码开销。
|
|
encoded, changed := redactBytes(body, matchedRedactionRules)
|
|
if changed {
|
|
restoreBody(request, encoded)
|
|
} else {
|
|
// 字节级替换未生效(如规则只匹配解码后文本但替换失败):
|
|
// 不得谎报已脱敏,否则 X-Gateway-Content-Redacted 与审计
|
|
// 都声称敏感信息已被移除,而实际请求原样发往上游。
|
|
result.Redacted = false
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// redactBytes 逐字节扫描 JSON,仅对"位于 textual 字段(content/text/input/
|
|
// prompt/instructions)下的值字符串"应用脱敏规则,其余字节(键名、空白、
|
|
// 数字、布尔、结构符)原样保留。与解码后替换相比:
|
|
// - 字节流与原始请求一致,除被替换的匹配段外零改动,不破坏上游签名/
|
|
// 哈希与字节级契约,也不做大 body 的双倍编解码;
|
|
// - 字符串内的 JSON 转义按原文匹配(如 \uXXXX),secret 模式通常不含
|
|
// 需要转义的字符,实际影响可忽略。
|
|
//
|
|
// 返回替换后的字节与是否发生过替换。
|
|
func redactBytes(raw []byte, rules []compiledRule) ([]byte, bool) {
|
|
if len(rules) == 0 {
|
|
return raw, false
|
|
}
|
|
type frame struct {
|
|
inObject bool
|
|
selected bool
|
|
}
|
|
changed := false
|
|
out := make([]byte, 0, len(raw)+64)
|
|
curSelected := false
|
|
keySelected := false
|
|
stack := make([]frame, 0, 8)
|
|
for i := 0; i < len(raw); {
|
|
ch := raw[i]
|
|
switch ch {
|
|
case '{', '[':
|
|
stack = append(stack, frame{inObject: ch == '{', selected: curSelected})
|
|
out = append(out, ch)
|
|
i++
|
|
continue
|
|
case '}', ']':
|
|
if len(stack) > 0 {
|
|
curSelected = stack[len(stack)-1].selected
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
out = append(out, ch)
|
|
i++
|
|
continue
|
|
case ',':
|
|
// 对象内逗号后是键:selected 由下一个键决定;数组内逗号后是
|
|
// 元素,继承当前 selected。
|
|
if len(stack) > 0 && stack[len(stack)-1].inObject {
|
|
curSelected = false
|
|
}
|
|
out = append(out, ch)
|
|
i++
|
|
continue
|
|
case ':':
|
|
// 键后冒号:值字符串的 selected 由该键决定。
|
|
curSelected = keySelected
|
|
out = append(out, ch)
|
|
i++
|
|
continue
|
|
case '"':
|
|
// 定位字符串结束(处理反斜杠转义)。
|
|
j := i + 1
|
|
escaped := false
|
|
for j < len(raw) {
|
|
if escaped {
|
|
escaped = false
|
|
j++
|
|
continue
|
|
}
|
|
if raw[j] == '\\' {
|
|
escaped = true
|
|
j++
|
|
continue
|
|
}
|
|
if raw[j] == '"' {
|
|
break
|
|
}
|
|
j++
|
|
}
|
|
if j >= len(raw) {
|
|
// 截断/畸形 JSON:剩余字节原样保留。
|
|
out = append(out, raw[i:]...)
|
|
break
|
|
}
|
|
content := raw[i+1 : j]
|
|
// 键还是值:字符串后第一个非空白字符是 ':' 即为对象键。
|
|
k := j + 1
|
|
for k < len(raw) && (raw[k] == ' ' || raw[k] == '\t' || raw[k] == '\n' || raw[k] == '\r') {
|
|
k++
|
|
}
|
|
isKey := k < len(raw) && raw[k] == ':'
|
|
if isKey {
|
|
// textual 字段名传播到其值:父级 selected 或键名命中。
|
|
parentSelected := false
|
|
if len(stack) > 0 {
|
|
parentSelected = stack[len(stack)-1].selected
|
|
}
|
|
keySelected = parentSelected || textualFields[strings.ToLower(string(content))]
|
|
} else if curSelected {
|
|
if replaced, hit := applyRules(content, rules); hit {
|
|
changed = true
|
|
out = append(out, '"')
|
|
out = append(out, replaced...)
|
|
out = append(out, '"')
|
|
i = j + 1
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, raw[i:j+1]...)
|
|
i = j + 1
|
|
continue
|
|
default:
|
|
out = append(out, ch)
|
|
i++
|
|
}
|
|
}
|
|
return out, changed
|
|
}
|
|
|
|
func applyRules(value []byte, rules []compiledRule) ([]byte, bool) {
|
|
changed := false
|
|
text := string(value)
|
|
for _, rule := range rules {
|
|
if rule.expression.MatchString(text) {
|
|
next := rule.expression.ReplaceAllString(text, jsonEscapeReplacement(rule.replacement))
|
|
if next != text {
|
|
text = next
|
|
changed = true
|
|
}
|
|
continue
|
|
}
|
|
// 原文(含 JSON 转义)未命中,但解码后的文本可能命中
|
|
// (Go 的 json.Marshal 会把 - & < > 等转义为 \u002d \u0026 ...):
|
|
// 对解码文本应用替换后重新做 JSON 字符串转义,其余字节不变。
|
|
var decoded string
|
|
wrapped := append([]byte(`"`), value...)
|
|
wrapped = append(wrapped, '"')
|
|
if json.Unmarshal(wrapped, &decoded) == nil && decoded != text && rule.expression.MatchString(decoded) {
|
|
next := rule.expression.ReplaceAllString(decoded, rule.replacement)
|
|
quoted := strconv.Quote(next)
|
|
text = quoted[1 : len(quoted)-1]
|
|
changed = true
|
|
}
|
|
}
|
|
return []byte(text), changed
|
|
}
|
|
|
|
// jsonEscapeReplacement 把替换文本转义成可安全嵌入 JSON 字符串字面量的
|
|
// 形式:替换文本含引号/反斜杠/控制字符时直接插入会破坏 JSON 结构。
|
|
func jsonEscapeReplacement(value string) string {
|
|
if !strings.ContainsAny(value, "\"\\\n\r\t") && !strings.ContainsFunc(value, func(r rune) bool { return r < 0x20 }) {
|
|
return value
|
|
}
|
|
quoted := strconv.Quote(value)
|
|
return quoted[1 : len(quoted)-1]
|
|
}
|
|
|
|
func policyApplies(policy compiledPolicy, path, model, apiKeyID string) bool {
|
|
return matches(policy.Paths, path) && matches(policy.Models, model) && matches(policy.APIKeyIDs, apiKeyID)
|
|
}
|
|
|
|
func matches(values []string, candidate string) bool {
|
|
if len(values) == 0 {
|
|
return true
|
|
}
|
|
for _, value := range values {
|
|
if value == "*" || value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var textualFields = map[string]bool{"content": true, "text": true, "input": true, "prompt": true, "instructions": true}
|
|
|
|
func walkText(value *any, selected bool, transform func(string) string) {
|
|
switch current := (*value).(type) {
|
|
case map[string]any:
|
|
for key, child := range current {
|
|
local := child
|
|
walkText(&local, selected || textualFields[strings.ToLower(key)], transform)
|
|
current[key] = local
|
|
}
|
|
case []any:
|
|
for index, child := range current {
|
|
local := child
|
|
walkText(&local, selected, transform)
|
|
current[index] = local
|
|
}
|
|
case string:
|
|
if selected {
|
|
*value = transform(current)
|
|
}
|
|
}
|
|
}
|
|
|
|
func findModel(document any) string {
|
|
if object, ok := document.(map[string]any); ok {
|
|
if model, ok := object["model"].(string); ok {
|
|
return model
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func restoreBody(request *http.Request, body []byte) {
|
|
request.Body = io.NopCloser(bytes.NewReader(body))
|
|
request.ContentLength = int64(len(body))
|
|
request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
|
|
}
|