Files
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

223 lines
8.8 KiB
Go
Raw Permalink 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 shared
import (
"net/http/httptest"
"testing"
"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 = "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" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
// 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 = "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 != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
}
}
// 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" // 落在已登记的回源网段内
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" {
t.Fatalf("ResolveClientIP() = %q, want EdgeOne client IP", got)
}
}
// TestResolveClientIPPrefersCloudflareOverEdgeOne 锁定权威头的优先级:
// 两个头并存(配置遗漏剥离规则时的异常态)时以 CF-Connecting-IP 为准,行为可预期。
func TestResolveClientIPPrefersCloudflareOverEdgeOne(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 = "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" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
// TestResolveClientIPEdgeOneInvalidFallsBack 验证 EO-Client-IP 为非法值时
// 不 panic 且回退到 RemoteAddr(与 CF-Connecting-IP 的健壮性一致)。
func TestResolveClientIPEdgeOneInvalidFallsBack(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 = "127.0.0.1:443"
r.Header.Set("EO-Client-IP", "not-an-ip")
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 内轮换海量地址绕过阈值、并避免键无限膨胀);
// - 非法输入:原样返回,保持调用方行为可预期。
func TestLimitKey(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"ipv4 passthrough", "203.0.113.10", "203.0.113.10"},
{"ipv4-mapped collapses to v4", "::ffff:203.0.113.10", "203.0.113.10"},
{"ipv6 aggregated to /64", "2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"},
// LimitKey 假定输入已由 ResolveClientIP 归一化(已剥离端口)。若误传入带端口的
// 字符串,ParseIP 失败会原样透传 —— 这是防御性约定,需锁定以免行为漂移。
{"ipv4 with port passes through", "203.0.113.10:8080", "203.0.113.10:8080"},
{"ipv6 with port passes through", "[2001:db8:1:2:3:4:5:6]:443", "[2001:db8:1:2:3:4:5:6]:443"},
{"invalid input passthrough", "not-an-ip", "not-an-ip"},
{"empty passthrough", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := LimitKey(tc.in); got != tc.want {
t.Fatalf("LimitKey(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestLimitKeyIPv6SamePrefixCollapses 验证:同一 /64 内任意两台主机应聚合到同一键,
// 不同 /64 必须落到不同键 —— 这是限速 / 封禁抗 IPv6 轮换绕过的核心不变量。
func TestLimitKeyIPv6SamePrefixCollapses(t *testing.T) {
a := LimitKey("2001:db8:1:2:3:4:5:6")
b := LimitKey("2001:db8:1:2:ff:fe::1") // 同一 /64,仅主机位不同
if a != b {
t.Fatalf("同一 /64 内地址应聚合到同一键,got %q vs %q", a, b)
}
c := LimitKey("2001:db8:1:3:3:4:5:6") // 不同 /64
if a == c {
t.Fatalf("不同 /64 应落到不同键,两者均为 %q", a)
}
}