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
+38 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"regexp"
"strings"
@@ -23,6 +24,7 @@ type AdminHTTPHandler struct {
changeHook func(context.Context) error
operations AdminOperations
mux *http.ServeMux
logger *slog.Logger
}
func (h *AdminHTTPHandler) SetChangeHook(hook func(context.Context) error) {
@@ -63,11 +65,12 @@ type modelRouteConditions struct {
func NewAdminHTTPHandler(repository *Repository, cipher *CredentialCipher, identityService *identity.Service, allowPrivate bool) *AdminHTTPHandler {
handler := &AdminHTTPHandler{
repository: repository, cipher: cipher, identity: identityService,
allowPrivate: allowPrivate, mux: http.NewServeMux(),
allowPrivate: allowPrivate, mux: http.NewServeMux(), logger: slog.Default(),
}
handler.mux.HandleFunc("GET /api/v1/admin/providers", handler.list)
handler.mux.HandleFunc("POST /api/v1/admin/providers", handler.create)
handler.mux.HandleFunc("PUT /api/v1/admin/providers/{provider_id}", handler.update)
handler.mux.HandleFunc("DELETE /api/v1/admin/providers/{provider_id}", handler.delete)
handler.mux.HandleFunc("POST /api/v1/admin/providers/{provider_id}/test", handler.testConnection)
handler.mux.HandleFunc("GET /api/v1/admin/providers/{provider_id}/models", handler.listModels)
handler.mux.HandleFunc("POST /api/v1/admin/providers/{provider_id}/models/sync", handler.syncModels)
@@ -138,6 +141,19 @@ func (h *AdminHTTPHandler) create(writer http.ResponseWriter, request *http.Requ
apiresponse.OK(writer, view)
}
func (h *AdminHTTPHandler) delete(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage)
if !ok {
return
}
if err := h.repository.Delete(request.Context(), request.PathValue("provider_id"), actor.ID); err != nil {
h.writeError(writer, err)
return
}
h.propagateChange(request.Context())
apiresponse.OK(writer, map[string]bool{"deleted": true})
}
func (h *AdminHTTPHandler) update(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage)
if !ok {
@@ -244,20 +260,30 @@ func (h *AdminHTTPHandler) setCredentials(record *Record, apiKey string) error {
}
func (h *AdminHTTPHandler) view(record Record) (map[string]any, error) {
// 凭据解密失败(KEK 轮换后旧记录、数据损坏)不得让整个列表接口报错:
// 降级为"未确认"状态并附警示,管理端仍可编辑/删除该记录恢复。
keyConfigured := false
masked := ""
credentialError := ""
plaintext, err := h.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion)
if err != nil {
return nil, err
}
var credentials Credentials
if err := json.Unmarshal(plaintext, &credentials); err != nil {
return nil, err
credentialError = "凭据无法解密(加密密钥不匹配或数据损坏),请重新保存凭据"
} else {
var credentials Credentials
if json.Unmarshal(plaintext, &credentials) != nil {
credentialError = "凭据数据格式无效,请重新保存凭据"
} else {
keyConfigured = credentials.APIKey != ""
masked = maskSecret(credentials.APIKey)
}
}
return map[string]any{
"id": record.ID, "code": record.Code, "adapter": record.Adapter,
"base_url": record.BaseURL, "capabilities": record.Capabilities,
"config": record.Config, "enabled": record.Enabled, "revision": record.Revision,
"credential_kek_version": record.CredentialKEKVersion,
"key_configured": credentials.APIKey != "", "api_key_masked": maskSecret(credentials.APIKey),
"key_configured": keyConfigured, "api_key_masked": masked,
"credential_error": credentialError,
}, nil
}
@@ -482,6 +508,11 @@ func (h *AdminHTTPHandler) writeError(writer http.ResponseWriter, err error) {
apiresponse.Error(writer, http.StatusServiceUnavailable, "供应商配置服务暂不可用")
case errors.Is(err, ErrProviderUpstream):
apiresponse.Error(writer, http.StatusBadGateway, "无法从上游供应商获取模型信息")
case errors.Is(err, ErrBlockedAddress):
// 原始错误含解析出的地址(如 "blocked address 10.0.0.1"),泄露内网
// 拓扑;细节只进服务端日志,客户端返回通用提示。
h.logger.Warn("provider URL blocked", "error", err)
apiresponse.Error(writer, http.StatusBadRequest, "供应商地址不允许访问内网或保留网段")
default:
apiresponse.Error(writer, http.StatusBadRequest, err.Error())
}
+6 -29
View File
@@ -128,7 +128,10 @@ func (s *Service) RotateCredentials(ctx context.Context, actorID string) (provid
}
plaintext, err := s.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion)
if err != nil {
return provider.CredentialRotationResult{}, fmt.Errorf("provider %s credentials cannot be decrypted: %w", record.Code, err)
// 单条损坏(如 KEK 版本被删除)不阻塞其余 Provider 的轮换:
// 跳过并计数,管理端从 Skipped 明细中定位问题记录。
result.Skipped++
continue
}
ciphertext, version, err := s.cipher.Encrypt(plaintext)
if err != nil {
@@ -229,34 +232,8 @@ func hasCapability(capabilities []string, expected string) bool {
}
func safeDialContext(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
if allowPrivate {
return dialer.DialContext
}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, errors.New("upstream host did not resolve")
}
for _, address := range addresses {
if !isPublicAddress(address.IP) {
return nil, fmt.Errorf("upstream resolved to blocked address %s", address.IP)
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
}
}
func isPublicAddress(ip net.IP) bool {
return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
// 统一复用 provider.IsPublicAddress 的完整网段判定(含 CGNAT/6to4/NAT64 等)。
return provider.SafeDialContext(allowPrivate, 5*time.Second, 30*time.Second)
}
var _ provider.AdminOperations = (*Service)(nil)
+3
View File
@@ -39,6 +39,9 @@ func (a *Adapter) Capabilities() []provider.Capability {
}
func (a *Adapter) Prepare(request *http.Request) {
// 无条件剥离客户端凭据:客户端携带的网关 API Key 绝不能转发给上游。
// 只有配置了 Provider 自身凭据时才注入 Authorization。
request.Header.Del("Authorization")
request.Header.Del("X-Gateway-API-Key")
request.Header.Del("X-Gateway-Provider")
basePath := strings.TrimRight(a.target.Path, "/")
+33
View File
@@ -250,6 +250,39 @@ func (r *Repository) Get(ctx context.Context, id string) (Record, error) {
return record, mapProviderError(err)
}
// Delete removes a provider and its cascaded model routes / synced models
// (FKs are ON DELETE CASCADE), emitting a provider.deleted outbox event in the
// same transaction.
func (r *Repository) Delete(ctx context.Context, id, actorID string) error {
if r.pool == nil {
return ErrProviderStore
}
eventID, err := platformid.NewUUID()
if err != nil {
return err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("%w: %v", ErrProviderStore, err)
}
defer func() { _ = tx.Rollback(ctx) }()
result, err := tx.Exec(ctx, `DELETE FROM gateway.providers WHERE id=$1 AND tenant_id IS NULL`, id)
if err != nil {
return fmt.Errorf("%w: %v", ErrProviderStore, err)
}
if result.RowsAffected() == 0 {
return ErrProviderNotFound
}
payload, _ := json.Marshal(map[string]any{"provider_id": id, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, 'provider.deleted', 1, 'provider', $2, $3)`, eventID, id, payload); err != nil {
return fmt.Errorf("%w: %v", ErrProviderStore, err)
}
return tx.Commit(ctx)
}
func (r *Repository) Create(ctx context.Context, record Record, actorID string) (Record, error) {
if r.pool == nil {
return Record{}, ErrProviderStore
+16 -2
View File
@@ -107,13 +107,27 @@ func (r *Resolver) ResolveModelRoute(query gateway.ModelRouteQuery) (gateway.Mod
}
total := 0
for _, route := range matched {
// 防御:weight<=0 的行(绕过管理端校验直接入库的脏数据)不得参与
// 权重池,否则 total=0 时取模除零 panic,同一请求 ID 将永远 500。
if route.weight <= 0 {
continue
}
total += route.weight
}
weighted := make([]compiledRoute, 0, len(matched))
for _, route := range matched {
if route.weight > 0 {
weighted = append(weighted, route)
}
}
if len(weighted) == 0 {
return gateway.ModelRouteResult{Known: known}, nil
}
hasher := fnv.New64a()
_, _ = hasher.Write([]byte(query.Seed + "\x00" + query.Model))
selected := int(hasher.Sum64() % uint64(total))
chosen := matched[len(matched)-1]
for _, route := range matched {
chosen := weighted[len(weighted)-1]
for _, route := range weighted {
if selected < route.weight {
chosen = route
break
+82 -5
View File
@@ -5,10 +5,16 @@ import (
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"strings"
"time"
)
// ErrBlockedAddress 标记 base_url 解析到被禁止的网段(私网/特殊用途网段)。
// 该错误携带解析出的地址细节,只应记录在服务端日志,不得原样返回给客户端。
var ErrBlockedAddress = errors.New("base_url resolves to a blocked address")
func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
@@ -29,8 +35,8 @@ func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string
return "", errors.New("base_url host did not resolve")
}
for _, address := range addresses {
if !isPublicAddress(address.IP) {
return "", fmt.Errorf("base_url resolves to blocked address %s", address.IP)
if !IsPublicAddress(address.IP) {
return "", fmt.Errorf("%w %s", ErrBlockedAddress, address.IP)
}
}
}
@@ -38,7 +44,78 @@ func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string
return parsed.String(), nil
}
func isPublicAddress(ip net.IP) bool {
return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
// specialPurposePrefixes 是 Go netip 内建分类(loopback/private/link-local/
// multicast/unspecified)之外、但绝不应作为出站上游的 IANA 特殊用途网段。
// 内网服务常部署在 CGNAT(100.64/10)与 benchmark(198.18/15)段,而 6to4/NAT64
// 前缀可以把 IPv6 地址桥接回内网 IPv4,因此必须一并拦截。
var specialPurposePrefixes = []netip.Prefix{
// IPv4 特殊用途网段(RFC 6890 及其更新)。
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT 共享地址空间 RFC 6598
netip.MustParsePrefix("192.0.0.0/24"), // IETF 协议保留
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 文档
netip.MustParsePrefix("192.88.99.0/24"), // 6to4 中继任播(已弃用)
netip.MustParsePrefix("198.18.0.0/15"), // 基准测试 RFC 2544
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 文档
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 文档
netip.MustParsePrefix("240.0.0.0/4"), // 保留(含广播地址)
// IPv6 特殊用途网段。
netip.MustParsePrefix("2001:db8::/32"), // 文档地址
netip.MustParsePrefix("2001:10::/28"), // ORCHID
netip.MustParsePrefix("2002::/16"), // 6to4:内嵌 IPv4,可桥接回内网
netip.MustParsePrefix("64:ff9b::/96"), // NAT64 知名前缀
netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 本地使用前缀
}
// IsPublicAddress 报告 ip 是否为可安全访问的公网单播地址。IPv4-mapped
// IPv6(::ffff:a.b.c.d)先解映射为 IPv4 再判断,防止绕过。SSRF 防护统一使用
// 本函数,写入校验与拨号时校验共用同一份判定。
func IsPublicAddress(ip net.IP) bool {
if ip == nil {
return false
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
if !addr.IsValid() || addr.IsUnspecified() || addr.IsLoopback() || addr.IsMulticast() ||
addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsInterfaceLocalMulticast() ||
addr.IsPrivate() {
return false
}
for _, prefix := range specialPurposePrefixes {
if prefix.Contains(addr) {
return false
}
}
return true
}
// SafeDialContext 构造拨号函数:allowPrivate 为 false 时,在拨号前对解析出的
// 全部地址做 IsPublicAddress 校验,并按校验通过的地址直连(不再二次解析,
// 缓解 DNS rebinding)。gateway 数据平面与控制面客户端共用此实现。
func SafeDialContext(allowPrivate bool, timeout, keepAlive time.Duration) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: timeout, KeepAlive: keepAlive}
if allowPrivate {
return dialer.DialContext
}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, errors.New("upstream host did not resolve")
}
for _, address := range addresses {
if !IsPublicAddress(address.IP) {
return nil, fmt.Errorf("upstream resolved to blocked address %s", address.IP)
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
}
}