链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。
- 认证端点限速:/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 段。
77 lines
2.3 KiB
Go
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)
|
|
}
|
|
}
|