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:
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -11,20 +12,22 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
Server Server
|
||||
Database Database
|
||||
Redis Redis
|
||||
Security Security
|
||||
Auth Auth
|
||||
Credentials Credentials
|
||||
Upstream Upstream
|
||||
Audit Audit
|
||||
Outbox Outbox
|
||||
RuntimeData RuntimeData
|
||||
Environment string
|
||||
Server Server
|
||||
Database Database
|
||||
Redis Redis
|
||||
Security Security
|
||||
Auth Auth
|
||||
Credentials Credentials
|
||||
Upstream Upstream
|
||||
Audit Audit
|
||||
Outbox Outbox
|
||||
RuntimeData RuntimeData
|
||||
Shadow Shadow
|
||||
ObjectStorage ObjectStorage
|
||||
Embeddings Embeddings
|
||||
Inbox Inbox
|
||||
Scheduler Scheduler
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -58,6 +61,7 @@ type Auth struct {
|
||||
LockDuration time.Duration
|
||||
LoginRateLimitMax int // 单 IP 滑动窗口内的最大登录尝试次数
|
||||
LoginRateLimitWindow time.Duration // 登录限流滑动窗口
|
||||
TrustedProxies []netip.Prefix // 可信反向代理网段;仅来自这些对端的 X-Forwarded-For 被采信
|
||||
}
|
||||
|
||||
type Credentials struct {
|
||||
@@ -87,6 +91,7 @@ type Audit struct {
|
||||
FlushInterval time.Duration
|
||||
Retention time.Duration
|
||||
UsageRetention time.Duration
|
||||
TraceRetention time.Duration
|
||||
PartitionMonthsAhead int
|
||||
MaintenanceInterval time.Duration
|
||||
}
|
||||
@@ -137,6 +142,20 @@ type Embeddings struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Inbox 配置站内消息(M8 P4)。Channel 是通知 worker 落库后 PUBLISH 的 Redis 频道,
|
||||
// 供未来实时推送订阅;未读数以 PostgreSQL 为权威源,不依赖 Redis。
|
||||
type Inbox struct {
|
||||
Channel string
|
||||
}
|
||||
|
||||
type Scheduler struct {
|
||||
GatewayBaseURL string
|
||||
PollInterval time.Duration
|
||||
ExecutionTimeout time.Duration
|
||||
BatchSize int
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: env("APP_ENV", "local"),
|
||||
@@ -167,6 +186,7 @@ func Load() (Config, error) {
|
||||
LockDuration: duration("LOGIN_LOCK_DURATION", 15*time.Minute),
|
||||
LoginRateLimitMax: intValue("LOGIN_RATE_LIMIT_MAX", 30),
|
||||
LoginRateLimitWindow: duration("LOGIN_RATE_LIMIT_WINDOW", 5*time.Minute),
|
||||
TrustedProxies: parsePrefixList(env("TRUSTED_PROXIES", "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7")),
|
||||
},
|
||||
Credentials: Credentials{
|
||||
MasterKey: strings.TrimSpace(os.Getenv("CREDENTIAL_MASTER_KEY")),
|
||||
@@ -186,7 +206,8 @@ func Load() (Config, error) {
|
||||
Audit: Audit{
|
||||
QueueSize: intValue("AUDIT_QUEUE_SIZE", 4096), BatchSize: intValue("AUDIT_BATCH_SIZE", 200),
|
||||
FlushInterval: duration("AUDIT_FLUSH_INTERVAL", time.Second), Retention: duration("AUDIT_RETENTION", 90*24*time.Hour),
|
||||
UsageRetention: duration("USAGE_RETENTION", 730*24*time.Hour), PartitionMonthsAhead: intValue("AUDIT_PARTITION_MONTHS_AHEAD", 3),
|
||||
UsageRetention: duration("USAGE_RETENTION", 730*24*time.Hour), TraceRetention: duration("TRACE_RETENTION", 90*24*time.Hour),
|
||||
PartitionMonthsAhead: intValue("AUDIT_PARTITION_MONTHS_AHEAD", 3),
|
||||
MaintenanceInterval: duration("AUDIT_MAINTENANCE_INTERVAL", 6*time.Hour),
|
||||
},
|
||||
Outbox: Outbox{
|
||||
@@ -222,6 +243,16 @@ func Load() (Config, error) {
|
||||
BatchSize: intValue("EMBEDDING_BATCH_SIZE", 64),
|
||||
Timeout: duration("EMBEDDING_TIMEOUT", 120*time.Second),
|
||||
},
|
||||
Inbox: Inbox{
|
||||
Channel: env("INBOX_CHANNEL", "gateway:inbox:events"),
|
||||
},
|
||||
Scheduler: Scheduler{
|
||||
GatewayBaseURL: strings.TrimRight(env("SCHEDULER_GATEWAY_BASE_URL", "http://gateway-api:8080"), "/"),
|
||||
PollInterval: duration("SCHEDULER_POLL_INTERVAL", 5*time.Second),
|
||||
ExecutionTimeout: duration("SCHEDULER_EXECUTION_TIMEOUT", 5*time.Minute),
|
||||
BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10),
|
||||
MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3),
|
||||
},
|
||||
}
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
@@ -235,7 +266,7 @@ func (c Config) Validate() error {
|
||||
if c.Database.MinConns < 0 || c.Database.MaxConns < 1 || c.Database.MinConns > c.Database.MaxConns {
|
||||
errs = append(errs, errors.New("database pool sizes are invalid"))
|
||||
}
|
||||
if c.Auth.SessionTTL < 5*time.Minute || c.Auth.TOTPChallengeTTL < time.Minute || c.Auth.TOTPChallengeTTL > 15*time.Minute || c.Auth.MaxFailures < 1 || c.Auth.LockDuration < time.Minute {
|
||||
if c.Auth.SessionTTL < 5*time.Minute || c.Auth.SessionTTL > 7*24*time.Hour || c.Auth.TOTPChallengeTTL < time.Minute || c.Auth.TOTPChallengeTTL > 15*time.Minute || c.Auth.MaxFailures < 1 || c.Auth.LockDuration < time.Minute {
|
||||
errs = append(errs, errors.New("authentication limits are invalid"))
|
||||
}
|
||||
if c.Auth.LoginRateLimitMax < 1 || c.Auth.LoginRateLimitWindow < time.Second {
|
||||
@@ -256,7 +287,7 @@ func (c Config) Validate() error {
|
||||
if c.Audit.QueueSize < 100 || c.Audit.QueueSize > 1_000_000 || c.Audit.BatchSize < 1 || c.Audit.BatchSize > c.Audit.QueueSize || c.Audit.FlushInterval < 100*time.Millisecond || c.Audit.FlushInterval > time.Minute {
|
||||
errs = append(errs, errors.New("audit buffering settings are invalid"))
|
||||
}
|
||||
if c.Audit.Retention < 24*time.Hour || c.Audit.Retention > 10*365*24*time.Hour || c.Audit.UsageRetention < c.Audit.Retention || c.Audit.UsageRetention > 10*365*24*time.Hour || c.Audit.PartitionMonthsAhead < 1 || c.Audit.PartitionMonthsAhead > 24 || c.Audit.MaintenanceInterval < time.Hour || c.Audit.MaintenanceInterval > 7*24*time.Hour {
|
||||
if c.Audit.Retention < 24*time.Hour || c.Audit.Retention > 10*365*24*time.Hour || c.Audit.UsageRetention < c.Audit.Retention || c.Audit.UsageRetention > 10*365*24*time.Hour || c.Audit.TraceRetention < 24*time.Hour || c.Audit.TraceRetention > 10*365*24*time.Hour || c.Audit.PartitionMonthsAhead < 1 || c.Audit.PartitionMonthsAhead > 24 || c.Audit.MaintenanceInterval < time.Hour || c.Audit.MaintenanceInterval > 7*24*time.Hour {
|
||||
errs = append(errs, errors.New("audit retention settings are invalid"))
|
||||
}
|
||||
if !strings.Contains(c.Outbox.Stream, "{outbox}") || c.Outbox.BatchSize < 1 || c.Outbox.BatchSize > 1000 || c.Outbox.PollInterval < 50*time.Millisecond || c.Outbox.PollInterval > time.Minute || c.Outbox.Lease < 5*time.Second || c.Outbox.Lease > 10*time.Minute || c.Outbox.MaxAttempts < 1 || c.Outbox.MaxAttempts > 100 || c.Outbox.MaxBackoff < time.Second || c.Outbox.MaxBackoff > time.Hour || c.Outbox.StreamMaxLength < 1000 || c.Outbox.StreamMaxLength > 100_000_000 || c.Outbox.MarkerTTL < 24*time.Hour || c.Outbox.MarkerTTL > 365*24*time.Hour {
|
||||
@@ -312,6 +343,12 @@ func (c Config) Validate() error {
|
||||
errs = append(errs, errors.New("EMBEDDING_TIMEOUT must be between 1s and 30m"))
|
||||
}
|
||||
}
|
||||
if err := validateHTTPURL(c.Scheduler.GatewayBaseURL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("SCHEDULER_GATEWAY_BASE_URL: %w", err))
|
||||
}
|
||||
if c.Scheduler.PollInterval < time.Second || c.Scheduler.PollInterval > time.Minute || c.Scheduler.ExecutionTimeout < time.Minute || c.Scheduler.ExecutionTimeout > time.Hour || c.Scheduler.BatchSize < 1 || c.Scheduler.BatchSize > 100 || c.Scheduler.MaxAttempts < 1 || c.Scheduler.MaxAttempts > 10 {
|
||||
errs = append(errs, errors.New("scheduler settings are invalid"))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -342,6 +379,15 @@ func (c Config) ValidateRuntime() error {
|
||||
errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is required in production"))
|
||||
}
|
||||
}
|
||||
// 拒绝已知弱默认密钥(所有环境,含本地 compose):deploy/docker-compose.yml
|
||||
// 曾把全零密钥作为默认值,凡是用该值加密的 Provider 凭据/TOTP 密钥/
|
||||
// Webhook 签名密钥,任何拿到仓库的人都能解密。
|
||||
if c.Credentials.MasterKey != "" {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Credentials.MasterKey)) {
|
||||
case "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", "change-me", "changeme", "password", "secret":
|
||||
errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is set to a known weak default; generate a strong random key with: openssl rand -base64 32"))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -356,6 +402,27 @@ func validateHTTPURL(raw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePrefixList 解析逗号分隔的 IP/CIDR 列表;非法项跳过并返回 nil 表示不信任任何代理。
|
||||
func parsePrefixList(raw string) []netip.Prefix {
|
||||
var prefixes []netip.Prefix
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
if addr, addrErr := netip.ParseAddr(part); addrErr == nil {
|
||||
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProductionCanDisableBootstrapCompatibility(t *testing.T) {
|
||||
t.Setenv("GATEWAY_BOOTSTRAP_API_KEY", "")
|
||||
t.Setenv("GATEWAY_BOOTSTRAP_API_KEY_ENABLED", "false")
|
||||
t.Setenv("UPSTREAM_FALLBACK_ENABLED", "false")
|
||||
t.Setenv("CREDENTIAL_MASTER_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
t.Setenv("CREDENTIAL_MASTER_KEY", "yP7sK9xR2mV4nQ8wT1uB3cE5fG6hJ0kL=")
|
||||
t.Setenv("UPSTREAM_BASE_URL", "https://example.com")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user