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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user