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 ./... 全通过。
This commit is contained in:
tamakyi
2026-10-27 12:37:40 +08:00
parent a3cf99a8da
commit 7802bb2078
32 changed files with 1579 additions and 145 deletions
+105 -10
View File
@@ -7,13 +7,31 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// setTrustedProxies 在测试内临时改 trusted_proxies 并重新初始化解析结果,
// 测试结束自动还原(trustedProxyNets 是包级状态,必须还原以免污染其它用例)。
func setTrustedProxies(t *testing.T, spec string) {
t.Helper()
old := conf.Security.TrustedProxies
conf.Security.TrustedProxies = spec
if err := InitTrustedProxies(); err != nil {
t.Fatalf("InitTrustedProxies(%q) error = %v", spec, err)
}
t.Cleanup(func() {
conf.Security.TrustedProxies = old
if err := InitTrustedProxies(); err != nil {
t.Fatalf("restore InitTrustedProxies() error = %v", err)
}
})
}
func TestResolveClientIPCloudflareHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
// 回环对端 = cloudflared / 本机反代回源,这是生产实际形态。
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.30")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -21,29 +39,88 @@ func TestResolveClientIPCloudflareHeader(t *testing.T) {
}
}
// TestResolveClientIPIPv6LoopbackHeader 验证 ::1 对端同样被视为本机回源。
func TestResolveClientIPIPv6LoopbackHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "[::1]:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
if got := ResolveClientIP(r); got != "198.51.100.20" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = false
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "192.0.2.10" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
}
}
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。
func TestResolveClientIPEdgeOneHeader(t *testing.T) {
// TestResolveClientIPRejectsForgedHeaderFromPublicPeer 是 P1-3 的回归测试。
//
// 攻击场景:双 CDN 并存时经 EdgeOne 打进来,EdgeOne 不剥离客户端自带的
// CF-Connecting-IP,旧实现无条件优先读它 → 客户端 IP 完全可控(限流 / 黑名单失效,
// 且可借 autoban 栽赃任意 IP)。修复后公网对端一律不采信权威头。
func TestResolveClientIPRejectsForgedHeaderFromPublicPeer(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "") // 未登记任何代理 / 回源网段
cases := []struct {
name string
header map[string]string
}{
{"forged CF header via EdgeOne", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
"EO-Client-IP": "198.51.100.99",
}},
{"forged CF header only", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
}},
{"forged EO header only", map[string]string{
"EO-Client-IP": "198.51.100.99",
}},
{"forged XFF and X-Real-IP", map[string]string{
"X-Forwarded-For": "198.51.100.20",
"X-Real-IP": "198.51.100.21",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 公网对端,不在 trusted_proxies 内
for k, v := range tc.header {
r.Header.Set(k, v)
}
if got := ResolveClientIP(r); got != "203.0.113.44" {
t.Fatalf("ResolveClientIP() = %q, want TCP remote address(不可伪造的对端地址)", got)
}
})
}
}
// TestResolveClientIPTrustedProxyHonorsCDNHeader 验证主动登记的 CDN 回源网段
// (或自己的 LB)仍可采信权威头 —— 这是「CDN 直连回源」部署形态的唯一正确配置方式。
func TestResolveClientIPTrustedProxyHonorsCDNHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "203.0.113.0/24")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 模拟 EdgeOne 边缘回源
r.RemoteAddr = "203.0.113.44:443" // 落在已登记的回源网段内
r.Header.Set("EO-Client-IP", "198.51.100.99")
r.Header.Set("X-Forwarded-For", "203.0.113.99")
if got := ResolveClientIP(r); got != "198.51.100.99" {
@@ -59,7 +136,7 @@ func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -75,13 +152,31 @@ func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("EO-Client-IP", "not-an-ip")
if got := ResolveClientIP(r); got != "203.0.113.44" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address fallback", got)
}
}
// TestResolveClientIPLoopbackDoesNotTrustXFF 锁定一条容易踩的边界:
// 回环对端只隐含信任「单一权威头」,不隐含信任 XFF / X-Real-IP(多跳链语义更弱)。
// 否则本机 cloudflared 场景下客户端只要多发一个 X-Real-IP 就能顶替权威头。
func TestResolveClientIPLoopbackDoesNotTrustXFF(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("X-Forwarded-For", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.21")
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address(回环不应隐含信任 XFF)", got)
}
}
// TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
// - IPv4:原样返回(每地址独立计数);
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀);