Files
openteam/server/internal/config/config.go
T
Sakurasan ad10bdc400 security: 补上认证面与输入边界的四处防护
链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。

- 认证端点限速:/auth/login、/auth/register 此前完全没接限速器(Limiter 只挂在代理
  Auth 上),爆破与批量建号零成本。新增 AllowWindow 滑动窗口,登录按「单 IP」和
  「单 IP×账号」两个桶(默认 30/min、10/min),注册按 IP(5/min)且在 argon2 之前拦,
  避免光靠建号请求打满 CPU;超限返回 429 + Retry-After。
- 生产启动断言:env=production 时拒绝默认 OT_JWT_SECRET、缺失或短于 16 字节的
  OT_MASTER_KEY(缺失时 NewEncryptor 会用 sha256("") 派生,渠道密文可被公开还原)、
  默认管理员密码;一次列全所有问题,不再改一个试一个。development 不拦。
- 请求体上限:parseBody 原来全量 io.ReadAll 且全项目没有一处 MaxBytesReader,
  合法密钥可把任意大 body 整份压进内存再转发。/v1 组挂 BodyLimit(默认 32MB,
  OT_PROXY_MAX_BODY_MB),超限按客户端协议返回 413 request_too_large 并点名上限。
- CORS:不再无条件反射 Origin + Allow-Credentials: true——那唯一挡住跨站凭据读取的
  是 refresh cookie 的 SameSite=Lax,改成 None 就立刻变成窃取面。改为显式白名单,
  空白名单完全不发跨域头(同源部署与 vite proxy 本来就不需要),通配 * 不配凭据;
  允许头补上 X-Api-Key / Anthropic-Version / OpenAI-*,否则浏览器里跑官方 SDK 预检会挂。
- 配套 OT_SERVER_TRUSTED_PROXIES:不配置就不信 X-Forwarded-For(防伪造头绕过 IP 限速)。

顺手删掉 .env.example 末尾重复的 OT_PROXY_LOG_RAW 段。
2026-10-17 03:25:30 +08:00

261 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。
// 所有项均可用环境变量 OT_<KEY> 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)。
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Env string // development | production
Port int
DB DBConfig
JWT JWTConfig
Auth AuthConfig
Proxy ProxyConfig
RateLimit RateLimitConfig
WebAuthn WebAuthnConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
// 管理员初始密码:仅用于生产启动断言,Seed 仍按原样直接读环境变量
AdminPassword string
// 跨域白名单(逗号分隔 origin)。为空 = 不输出任何 CORS 头:
// 网关自己托管 web/dist 时前后端同源,本就不需要 CORS;开发用 vite proxy 也不需要。
CORSAllowOrigins []string
// 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP,
// 认证限速依赖这个 IP;直连部署留空即可。
TrustedProxies []string
}
// WebAuthnConfig Passkey(WebAuthn)配置。
type WebAuthnConfig struct {
RPID string // Relying Party ID(域名,如 localhost)
RPOrigin string // 前端来源,如 http://localhost:5173
RPName string // 展示名
}
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
type RateLimitConfig struct {
UserRPS int // 用户级每秒请求数上限(0=不限制)
// 认证端点限速(每分钟):登录是密码爆破面,注册是批量建号面。0=不限制。
LoginIPPerMin int // 单 IP 登录尝试数
LoginUserPerMin int // 单 IP 对同一账号的登录尝试数(挡住换 IP 定向爆破)
RegisterIPPerMin int // 单 IP 注册次数
}
type DBConfig struct {
Driver string // sqlite | postgres
DSN string
}
type JWTConfig struct {
Secret string
AccessTTL time.Duration
RefreshTTL time.Duration
Issuer string
CookieName string
CookieSecure bool
CookieDomain string
}
type AuthConfig struct {
RegistrationMode string // open | invite
Argon2Time uint32
Argon2Memory uint32
Argon2Threads uint8
Argon2KeyLen uint32
SaltLen int
}
type ProxyConfig struct {
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
UpstreamBaseURL string // 渠道 base_url 默认值
UpstreamKey string // 渠道上游 key 默认值
DefaultModel string // 渠道模型导入时使用的模型名
Timeout time.Duration
MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413
HealthInterval time.Duration // 渠道健康检查周期
HealthFailThreshold int // 连续失败 N 次进 cooldown
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
}
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
// 已存在的环境变量优先,不覆盖。
func loadDotEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.Trim(strings.TrimSpace(v), `"'`)
if k != "" && os.Getenv(k) == "" {
_ = os.Setenv(k, v)
}
}
}
func Load() (*Config, error) {
loadDotEnv()
v := viper.New()
v.SetEnvPrefix("OT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// 默认值(与 .env.example 对应)
v.SetDefault("env", "development")
v.SetDefault("port", 8080)
v.SetDefault("db.driver", "sqlite")
v.SetDefault("db.dsn", "data/openteam.db")
v.SetDefault("jwt.secret", "dev-only-secret-change-me")
v.SetDefault("jwt.access_ttl", "2h")
v.SetDefault("jwt.refresh_ttl", "168h")
v.SetDefault("jwt.issuer", "openteam")
v.SetDefault("jwt.cookie_name", "ot_refresh")
v.SetDefault("jwt.cookie_secure", false)
v.SetDefault("jwt.cookie_domain", "")
v.SetDefault("auth.registration_mode", "open")
v.SetDefault("auth.argon2_time", 3)
v.SetDefault("auth.argon2_memory", 64*1024) // 64 MiB
v.SetDefault("auth.argon2_threads", 2)
v.SetDefault("auth.argon2_keylen", 32)
v.SetDefault("auth.salt_len", 16)
v.SetDefault("proxy.default_channel_name", "openai")
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
v.SetDefault("proxy.upstream_key", "")
v.SetDefault("proxy.default_model", "gpt-4o-mini")
v.SetDefault("proxy.timeout", "300s")
v.SetDefault("proxy.max_body_mb", 32)
v.SetDefault("proxy.health_interval", "60s")
v.SetDefault("proxy.health_fail_threshold", 2)
v.SetDefault("proxy.log_raw", false)
v.SetDefault("ratelimit.user_rps", 20)
v.SetDefault("ratelimit.login_ip_per_min", 30)
v.SetDefault("ratelimit.login_user_per_min", 10)
v.SetDefault("ratelimit.register_ip_per_min", 5)
v.SetDefault("cors.allow_origins", "")
v.SetDefault("server.trusted_proxies", "")
v.SetDefault("webauthn.rp_id", "localhost")
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
v.SetDefault("webauthn.rp_name", "openteam")
cfg := &Config{
Env: v.GetString("env"),
Port: v.GetInt("port"),
DB: DBConfig{
Driver: v.GetString("db.driver"),
DSN: v.GetString("db.dsn"),
},
JWT: JWTConfig{
Secret: v.GetString("jwt.secret"),
AccessTTL: v.GetDuration("jwt.access_ttl"),
RefreshTTL: v.GetDuration("jwt.refresh_ttl"),
Issuer: v.GetString("jwt.issuer"),
CookieName: v.GetString("jwt.cookie_name"),
CookieSecure: v.GetBool("jwt.cookie_secure"),
CookieDomain: v.GetString("jwt.cookie_domain"),
},
Auth: AuthConfig{
RegistrationMode: v.GetString("auth.registration_mode"),
Argon2Time: v.GetUint32("auth.argon2_time"),
Argon2Memory: v.GetUint32("auth.argon2_memory"),
Argon2Threads: v.GetUint8("auth.argon2_threads"),
Argon2KeyLen: v.GetUint32("auth.argon2_keylen"),
SaltLen: v.GetInt("auth.salt_len"),
},
Proxy: ProxyConfig{
DefaultChannelName: v.GetString("proxy.default_channel_name"),
UpstreamBaseURL: v.GetString("proxy.upstream_base_url"),
UpstreamKey: v.GetString("proxy.upstream_key"),
DefaultModel: v.GetString("proxy.default_model"),
Timeout: v.GetDuration("proxy.timeout"),
MaxBodyMB: v.GetInt("proxy.max_body_mb"),
HealthInterval: v.GetDuration("proxy.health_interval"),
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
LogRaw: v.GetBool("proxy.log_raw"),
},
RateLimit: RateLimitConfig{
UserRPS: v.GetInt("ratelimit.user_rps"),
LoginIPPerMin: v.GetInt("ratelimit.login_ip_per_min"),
LoginUserPerMin: v.GetInt("ratelimit.login_user_per_min"),
RegisterIPPerMin: v.GetInt("ratelimit.register_ip_per_min"),
},
WebAuthn: WebAuthnConfig{
RPID: v.GetString("webauthn.rp_id"),
RPOrigin: v.GetString("webauthn.rp_origin"),
RPName: v.GetString("webauthn.rp_name"),
},
Master: v.GetString("master_key"),
AdminPassword: v.GetString("admin_password"),
CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")),
TrustedProxies: splitList(v.GetString("server.trusted_proxies")),
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// splitList 解析逗号分隔列表(去空白、忽略空项)。
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// validate 生产环境的密钥断言:这些默认值是公开已知的,忘配就等于没有认证。
//
// master key 缺失时 crypto.NewEncryptor 会用 sha256("") 派生密钥(结果同样公开),
// 渠道上游密钥的密文就变成谁都能解;jwt.secret 用默认值则能直接伪造管理员 token。
func (c *Config) validate() error {
if c.Env != "production" {
return nil
}
var problems []string
if c.JWT.Secret == "" || c.JWT.Secret == "dev-only-secret-change-me" {
problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)")
}
if len(c.Master) < 16 {
problems = append(problems, "OT_MASTER_KEY 未设置或少于 16 字节(缺失时会用 sha256(\"\") 派生,渠道密文可被公开还原)")
}
if c.AdminPassword == "" || c.AdminPassword == "admin123" {
problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123")
}
if len(problems) > 0 {
return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";"))
}
return nil
}