0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(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 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
+169 -4
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -176,6 +177,9 @@ func (e *Engine) Apply(request *http.Request, maxBody int64, apiKeyID string) (R
}
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
@@ -206,17 +210,178 @@ func (e *Engine) Apply(request *http.Request, maxBody int64, apiKeyID string) (R
break
}
result.Redacted = result.Redacted || changed
if policy.Action == "redact" && changed {
matchedRedactionRules = append(matchedRedactionRules, policy.rules...)
}
}
if result.Redacted && !result.Blocked {
encoded, err := json.Marshal(document)
if err != nil {
return Result{}, err
// 在原始请求字节上做字符串字面量级替换,而不是解码后重新
// json.Marshal:重编码会改变键序、数字格式与 HTML 转义,破坏上游
// 的请求签名/哈希与字节级契约,且对大 body 是双倍编解码开销。
encoded, changed := redactBytes(body, matchedRedactionRules)
if changed {
restoreBody(request, encoded)
} else {
// 字节级替换未生效(如规则只匹配解码后文本但替换失败):
// 不得谎报已脱敏,否则 X-Gateway-Content-Redacted 与审计
// 都声称敏感信息已被移除,而实际请求原样发往上游。
result.Redacted = false
}
restoreBody(request, encoded)
}
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)
}