Files
openteam/server/internal/config/config_test.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

77 lines
2.3 KiB
Go

package config
import (
"strings"
"testing"
)
func prodCfg() *Config {
c := &Config{Env: "production"}
c.JWT.Secret = "a-real-random-secret-not-the-default"
c.Master = "0123456789abcdef0123456789abcdef"
c.AdminPassword = "strong-enough-password"
return c
}
func TestValidateProduction(t *testing.T) {
if err := prodCfg().validate(); err != nil {
t.Fatalf("配齐密钥应通过:%v", err)
}
cases := []struct {
name string
mutate func(*Config)
want string
}{
{"默认 jwt secret", func(c *Config) { c.JWT.Secret = "dev-only-secret-change-me" }, "OT_JWT_SECRET"},
{"空 jwt secret", func(c *Config) { c.JWT.Secret = "" }, "OT_JWT_SECRET"},
{"缺失 master key", func(c *Config) { c.Master = "" }, "OT_MASTER_KEY"},
{"过短 master key", func(c *Config) { c.Master = "short" }, "OT_MASTER_KEY"},
{"默认管理员密码", func(c *Config) { c.AdminPassword = "admin123" }, "OT_ADMIN_PASSWORD"},
{"未设管理员密码", func(c *Config) { c.AdminPassword = "" }, "OT_ADMIN_PASSWORD"},
}
for _, tc := range cases {
c := prodCfg()
tc.mutate(c)
err := c.validate()
if err == nil {
t.Fatalf("%s:应被拒绝", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s:报错应点名 %s,实际 %v", tc.name, tc.want, err)
}
}
// 多项同时缺失要一次全说清,别让人改一个试一个
c := prodCfg()
c.JWT.Secret = ""
c.Master = ""
err := c.validate()
if err == nil || !strings.Contains(err.Error(), "OT_JWT_SECRET") || !strings.Contains(err.Error(), "OT_MASTER_KEY") {
t.Fatalf("应一次列出全部问题:%v", err)
}
// 开发环境不拦(本地跑默认值就是设计如此)
dev := prodCfg()
dev.Env = "development"
dev.JWT.Secret = "dev-only-secret-change-me"
dev.Master = ""
dev.AdminPassword = "admin123"
if err := dev.validate(); err != nil {
t.Fatalf("development 不应拦截:%v", err)
}
}
func TestSplitList(t *testing.T) {
if got := splitList(""); got != nil {
t.Fatalf("空串应为 nil,实际 %v", got)
}
if got := splitList(" , ,, "); got != nil {
t.Fatalf("纯分隔符应为 nil,实际 %v", got)
}
got := splitList("https://a.com, https://b.com ,,http://c.com")
if len(got) != 3 || got[0] != "https://a.com" || got[2] != "http://c.com" {
t.Fatalf("解析错误:%v", got)
}
}