Files
tamabox/internal/shared/attempt_test.go
T
tamakyi 7802bb2078 fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00

97 lines
2.9 KiB
Go

package shared
import (
"testing"
"time"
)
// TestAttemptLimiterLockout 覆盖 P1-2 的爆破防护契约:
// window 内失败 max 次 → 锁定 lock 时长;锁定期满后自动清零(不会永久锁死);
// 成功校验(Reset)后立即恢复。
func TestAttemptLimiterLockout(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
l := NewAttemptLimiter(3, 10*time.Minute, 5*time.Minute)
l.now = clock
const key = "page:1:203.0.113.10"
// 前两次失败不应锁定。
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("失败 2 次(阈值 3)就被锁定")
}
// 第 3 次失败达到阈值 → 锁定。
l.Fail(key)
blocked, remain := l.Blocked(key)
if !blocked {
t.Fatal("失败 3 次(阈值 3)未锁定")
}
if remain <= 0 || remain > 5*time.Minute {
t.Fatalf("剩余锁定时间异常:%v", remain)
}
// 锁定期内即使再失败也仍然锁定。
now = now.Add(time.Minute)
l.Fail(key)
if blocked, _ := l.Blocked(key); !blocked {
t.Fatal("锁定期内应保持锁定")
}
// 锁定期满 → 解锁,且计数被清零(重新拥有 max 次机会)。
now = now.Add(5 * time.Minute)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("锁定期满后未解锁")
}
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("解锁后计数未清零:2 次失败不应触发锁定")
}
// Reset(校验成功)清空计数。
l.Reset(key)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("Reset 后仍处于锁定状态")
}
}
// TestAttemptLimiterWindowExpiry 验证窗口外的失败记录会失效:
// 慢速爆破(每次间隔超过窗口)不应该被累计成锁定,但也不应该被当成成功。
func TestAttemptLimiterWindowExpiry(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
l := NewAttemptLimiter(3, 1*time.Minute, 5*time.Minute)
l.now = func() time.Time { return now }
const key = "page:1:203.0.113.10"
for i := 0; i < 5; i++ {
l.Fail(key)
now = now.Add(61 * time.Second) // 每次都越过窗口
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("窗口外的失败记录被计入,锁定过早触发")
}
}
// TestAttemptLimiterKeyIsolation 不同 key(不同箱主 / 不同来源 IP)必须互不影响:
// 否则一个人爆破就能把整个站点的密码入口锁死。
func TestAttemptLimiterKeyIsolation(t *testing.T) {
l := NewAttemptLimiter(2, time.Minute, time.Minute)
for i := 0; i < 2; i++ {
l.Fail("page:1:203.0.113.10")
}
if blocked, _ := l.Blocked("page:1:203.0.113.10"); !blocked {
t.Fatal("目标 key 应被锁定")
}
if blocked, _ := l.Blocked("page:1:203.0.113.11"); blocked {
t.Fatal("另一个来源 IP 被误锁")
}
if blocked, _ := l.Blocked("page:2:203.0.113.10"); blocked {
t.Fatal("另一个箱主被误锁")
}
}