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:
@@ -0,0 +1,114 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AttemptLimiter 按 key 统计「失败次数」,达到阈值后在 lock 时长内锁定该 key。
|
||||
//
|
||||
// 适用场景:密码、验证码这类可被穷举的入口。成功的尝试应调用 Reset 清零,
|
||||
// 失败的尝试调用 Fail;处于锁定状态时应直接拒绝,不要再比对密码 —— 既省掉
|
||||
// 一次密码哈希的计算,也不给攻击者「比对上更快的探测通道」。
|
||||
//
|
||||
// 实现约束:
|
||||
// - 纯内存、无后台 goroutine:仓库里已经踩过「每个限流器实例一个常驻 goroutine」
|
||||
// 的泄漏坑(见 internal/route/ratelimit.go 的统一清理循环),这里改用惰性清理;
|
||||
// - 键数量上限保护:恶意构造海量 key 时先清过期项,仍超限则丢弃最旧的键,
|
||||
// 避免 map 无限膨胀把进程内存吃满。
|
||||
type AttemptLimiter struct {
|
||||
mu sync.Mutex
|
||||
fails map[string][]time.Time
|
||||
max int
|
||||
window time.Duration
|
||||
lock time.Duration
|
||||
now func() time.Time // 仅测试替换为可控时钟
|
||||
}
|
||||
|
||||
// NewAttemptLimiter 创建失败计数器:window 内失败达到 max 次 → 锁定 lock 时长。
|
||||
func NewAttemptLimiter(max int, window, lock time.Duration) *AttemptLimiter {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
return &AttemptLimiter{
|
||||
fails: make(map[string][]time.Time),
|
||||
max: max,
|
||||
window: window,
|
||||
lock: lock,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked 判断 key 是否处于锁定状态,并返回剩余锁定时间。
|
||||
//
|
||||
// 锁定期满后自动清零,用户重新获得 max 次机会 —— 否则一次误触发就会永久锁死。
|
||||
func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := l.now()
|
||||
times := l.pruneLocked(key, now)
|
||||
if len(times) < l.max {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
unlockAt := times[len(times)-1].Add(l.lock)
|
||||
if now.Before(unlockAt) {
|
||||
return true, unlockAt.Sub(now)
|
||||
}
|
||||
|
||||
// 锁定期满:清空记录,重新开始计数。
|
||||
delete(l.fails, key)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Fail 记录一次失败。
|
||||
func (l *AttemptLimiter) Fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := l.now()
|
||||
times := l.pruneLocked(key, now)
|
||||
times = append(times, now)
|
||||
l.fails[key] = times
|
||||
|
||||
// 条数超限时清理全部已过期的键;仍超限则整表重建(宁可丢失计数,也不能 OOM)。
|
||||
if len(l.fails) > 5000 {
|
||||
for k := range l.fails {
|
||||
if t := l.fails[k]; len(t) == 0 || now.Sub(t[len(t)-1]) > l.window+l.lock {
|
||||
delete(l.fails, k)
|
||||
}
|
||||
}
|
||||
if len(l.fails) > 5000 {
|
||||
l.fails = map[string][]time.Time{key: times}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 清空该 key 的失败记录(校验成功后调用)。
|
||||
func (l *AttemptLimiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.fails, key)
|
||||
}
|
||||
|
||||
// pruneLocked 丢弃窗口外的失败记录,返回窗口内的切片(调用方持有 l.mu)。
|
||||
func (l *AttemptLimiter) pruneLocked(key string, now time.Time) []time.Time {
|
||||
times, ok := l.fails[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cutoff := now.Add(-l.window)
|
||||
valid := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
delete(l.fails, key)
|
||||
return nil
|
||||
}
|
||||
l.fails[key] = valid
|
||||
return valid
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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("另一个箱主被误锁")
|
||||
}
|
||||
}
|
||||
+70
-17
@@ -5,7 +5,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
@@ -100,33 +102,84 @@ func isTrustedProxy(ipStr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
|
||||
// headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。
|
||||
//
|
||||
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
|
||||
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
|
||||
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
|
||||
// 只有两种情况成立:
|
||||
// 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来;
|
||||
// 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。
|
||||
//
|
||||
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
|
||||
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
|
||||
// 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。
|
||||
// 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时,
|
||||
// EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头,
|
||||
// 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效,
|
||||
// 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。
|
||||
//
|
||||
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
|
||||
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
|
||||
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
func ResolveClientIP(r *http.Request) string {
|
||||
if !conf.Security.TrustProxy {
|
||||
return NormalizeIP(r.RemoteAddr)
|
||||
// 反过来说,只要要求「权威头只能由 CDN 写入」这一前提无法被代码验证,就不能把它当公理;
|
||||
// 唯一能被代码验证的依据是 TCP 对端地址,因此这里 fail-closed。
|
||||
func headerSourceTrusted(remote string) bool {
|
||||
if remote == "" {
|
||||
return false
|
||||
}
|
||||
if ip := net.ParseIP(remote); ip != nil && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
return isTrustedProxy(remote)
|
||||
}
|
||||
|
||||
// (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
|
||||
// cdnClientIPHeader 取请求里的 CDN 权威客户端 IP(Cloudflare 优先,其次腾讯 EdgeOne)。
|
||||
// 只负责取值与规范化,不判断来源是否可信 —— 可信性由 headerSourceTrusted 负责。
|
||||
func cdnClientIPHeader(r *http.Request) string {
|
||||
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
|
||||
return cfIP
|
||||
}
|
||||
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" {
|
||||
return eoIP
|
||||
return NormalizeIP(r.Header.Get("EO-Client-IP"))
|
||||
}
|
||||
|
||||
// warnIgnoredCDNHeaderOnce 进程内只告警一次:避免伪造请求把日志刷满,
|
||||
// 同时保证「链路与配置不匹配」这件事在上线后一定能被看见。
|
||||
var warnIgnoredCDNHeaderOnce sync.Once
|
||||
|
||||
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
|
||||
//
|
||||
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 仅当 TCP 对端可信
|
||||
// (回环或 trusted_proxies 命中)才采信 CDN 权威头 CF-Connecting-IP → EO-Client-IP,
|
||||
// (b) 仅当同一对端落在 trusted_proxies 内才从右向左解析 XFF / X-Real-IP,
|
||||
// (c) 否则回退 RemoteAddr —— 绝不采信客户端自供的头。
|
||||
//
|
||||
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
|
||||
// CF-Connecting-IP / EO-Client-IP。本函数现已 fail-closed(公网对端不采信),
|
||||
// 但仍建议用 Tunnel / 仅放行回源网段锁死源站,否则攻击者虽伪造不了身份,
|
||||
// 却仍能直接打到源站。
|
||||
//
|
||||
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)并存且都直连回源时,两家回源网段
|
||||
// 都必须登记到 trusted_proxies,否则该项 CDN 的权威头会被忽略、退化成按边缘 IP 聚合
|
||||
// (限流互相挤占、自动封禁误伤整片区域)。重新启用 EdgeOne 时请一并核对这里。
|
||||
func ResolveClientIP(r *http.Request) string {
|
||||
remote := NormalizeIP(r.RemoteAddr)
|
||||
if !conf.Security.TrustProxy {
|
||||
return remote
|
||||
}
|
||||
|
||||
// (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。
|
||||
remote := NormalizeIP(r.RemoteAddr)
|
||||
// (a) CDN 权威客户端头:只有对端可信时才采信。
|
||||
if headerSourceTrusted(remote) {
|
||||
if ip := cdnClientIPHeader(r); ip != "" {
|
||||
return ip
|
||||
}
|
||||
} else if forged := cdnClientIPHeader(r); forged != "" {
|
||||
// 公网对端却带了权威头:要么是 CDN 直连回源但没登记网段,要么是有人在伪造身份。
|
||||
// 只告警、不改变行为(行为已由上面的分支确定为「用 remote」),避免影响业务。
|
||||
warnIgnoredCDNHeaderOnce.Do(func() {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"remote_addr": remote,
|
||||
"header_ip": forged,
|
||||
}).Warn("来自不可信公网对端的请求携带了 CDN 权威客户端头,已忽略并改用 TCP 对端地址。" +
|
||||
"若站点由 CDN 直连回源(非 cloudflared Tunnel),请把该 CDN 的回源网段加入 " +
|
||||
"[security] trusted_proxies;若本不该出现此类请求,说明有人在尝试伪造客户端身份以绕过限流 / 封禁。")
|
||||
})
|
||||
}
|
||||
|
||||
// (b)/(c) XFF / X-Real-IP 仍只认 trusted_proxies:这两个头是多跳链、语义更弱,
|
||||
// 回环对端不隐含信任它们。
|
||||
if isTrustedProxy(remote) {
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
|
||||
// 从右向左取第一个非可信代理跳:客户端的真实 IP。
|
||||
|
||||
+105
-10
@@ -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 内轮换海量地址绕过阈值、并避免键无限膨胀);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SafeRedirectTarget 校验「跳转回原页面」这类参数,只放行站内绝对路径,不可信时返回空串。
|
||||
//
|
||||
// 为什么不能只判 `strings.HasPrefix(to, "/") && !strings.HasPrefix(to, "//")`:
|
||||
// WHATWG URL 在 special scheme(http/https)下把 `\` 视作 `/`,于是
|
||||
// `Location: /\evil.example` 会被浏览器解析成 `https://evil.example/` —— 一个可用的
|
||||
// 钓鱼跳板("登录后跳转异常,请重新输入密码")。浏览器在解析前还会剔除 URL 内部的
|
||||
// TAB/CR/LF,所以控制字符也必须先拒掉,否则服务端的判断与浏览器的解释不一致。
|
||||
//
|
||||
// 全站只有两个地方接受「跳到哪」的参数(登录后的 to、页面密码通过后的 next),
|
||||
// 两处都必须走这里 —— 之前它们各写一份判断,其中一份就漏了反斜杠。
|
||||
//
|
||||
// 返回空串表示「参数不可信」,由调用方回退到默认落地页。
|
||||
func SafeRedirectTarget(to string) string {
|
||||
if to == "" {
|
||||
return ""
|
||||
}
|
||||
// 控制字符与空格:前者会被浏览器剔除后重新解析,后者会被编码成 %20 改变语义。
|
||||
if strings.ContainsAny(to, "\\\x00\r\n\t ") {
|
||||
return ""
|
||||
}
|
||||
// 必须以单个斜杠开头:排掉相对路径、协议相对(//host)、绝对 URL(scheme:)。
|
||||
if !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(to)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// 解析成带 scheme / host / userinfo 的形态,说明上面两条判断被编码绕过了。
|
||||
if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
|
||||
return ""
|
||||
}
|
||||
// url.Parse 会把 %5c、%0d%0a 等编码解码进 Path。上面那轮检查看的是原始字符串,
|
||||
// 看不到解码后的形态,所以这里对解码结果再查一遍:反斜杠(浏览器当 /)与全部
|
||||
// C0 控制字符(响应头注入面,%0d%0a 能在 Location 里伪造新的一行头)。
|
||||
for _, r := range u.Path {
|
||||
if r == '\\' || r < 0x20 || r == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
// 归一化后再确认一次仍是站内绝对路径(吃掉 /..、/../x 之类的残留)。
|
||||
cleaned := path.Clean(u.Path)
|
||||
if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") {
|
||||
return ""
|
||||
}
|
||||
if u.RawQuery == "" {
|
||||
return cleaned
|
||||
}
|
||||
return cleaned + "?" + u.RawQuery
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSafeRedirectTarget 锁定「跳转回原页面」参数的白名单行为。
|
||||
//
|
||||
// 重点是那批"看起来是站内路径、浏览器却会当外站"的载荷:反斜杠变体
|
||||
// (WHATWG 在 http/https 下把 \ 当 /)、协议相对引用、以及编码后的同类载荷。
|
||||
// 修复前两处调用点各写一份判断,其中登录那处用 `path.Clean("/"+to)`,只挡住 //host 与绝对 URL,
|
||||
// 反斜杠直接漏到 Location 头(页面密码那处同样漏)。
|
||||
func TestSafeRedirectTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 空串表示"不可信,调用方回退默认落地页"
|
||||
}{
|
||||
// —— 合法站内路径:应当原样(或归一化后)放行 ——
|
||||
{"箱主页", "/_/someone", "/_/someone"},
|
||||
{"后台", "/user", "/user"},
|
||||
{"单题页带 token", "/_/someone/12?t=abc", "/_/someone/12?t=abc"},
|
||||
{"根路径", "/", "/"},
|
||||
{"多点归一化", "/user/../explore", "/explore"},
|
||||
{"编码斜杠归一化后仍在站内", "/%2f%2fevil.example", "/evil.example"},
|
||||
|
||||
// —— 必须拒绝的载荷 ——
|
||||
{"空串", "", ""},
|
||||
{"相对路径", "user", ""},
|
||||
{"反斜杠跳转", `/\evil.example`, ""},
|
||||
{"双反斜杠", `\\evil.example`, ""},
|
||||
{"混入反斜杠的站内路径", `/_/a\b`, ""},
|
||||
{"协议相对", "//evil.example", ""},
|
||||
{"三斜杠", "///evil.example", ""},
|
||||
{"绝对 http", "http://evil.example", ""},
|
||||
{"绝对 https", "https://evil.example", ""},
|
||||
{"同协议不同站", "https:/evil.example", ""},
|
||||
{"javascript 伪协议", "javascript:alert(1)", ""},
|
||||
{"data 伪协议", "data:text/html,<script>alert(1)</script>", ""},
|
||||
{"userinfo 形态", "/@evil.example", "/@evil.example"}, // 站内路径,host 为空
|
||||
{"编码反斜杠", `/%5cevil.example`, ""},
|
||||
{"大写编码反斜杠", `/%5Cevil.example`, ""},
|
||||
{"CRLF 注入", "/x%0d%0aSet-Cookie:%20a=b", ""},
|
||||
{"内嵌 TAB", "/\t/evil.example", ""},
|
||||
{"前导空格", " /evil.example", ""},
|
||||
{"尾部空格", "/evil.example ", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SafeRedirectTarget(tc.in)
|
||||
require.Equal(t, tc.want, got)
|
||||
|
||||
if got == "" {
|
||||
return
|
||||
}
|
||||
// 不变式:返回值必须是浏览器解析后仍留在本站的路径。
|
||||
// 只要 Host/Scheme 非空,或者以 // 开头,就会被浏览器当成外站。
|
||||
require.True(t, strings.HasPrefix(got, "/"), "返回值必须以 / 开头: %q", got)
|
||||
require.False(t, strings.HasPrefix(got, "//"), "返回值不能是协议相对引用: %q", got)
|
||||
require.NotContains(t, got, `\`, "返回值不能含反斜杠: %q", got)
|
||||
u, err := url.Parse(got)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, u.Host, "返回值不能带 host: %q", got)
|
||||
require.Empty(t, u.Scheme, "返回值不能带 scheme: %q", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeRedirectTargetNeverLeavesSite 是全量不变式的兜底:
|
||||
// 上面表的每一个输入(含被拒绝的)都必须满足"要么空、要么是站内绝对路径"。
|
||||
func TestSafeRedirectTargetNeverLeavesSite(t *testing.T) {
|
||||
payloads := []string{
|
||||
"/\\evil.example", `\\evil.example`, "//evil.example", "///evil.example",
|
||||
"https://evil.example", "http://evil.example", "//", "/%5c%5cevil.example",
|
||||
"/\u0000evil", "/\u00a0evil", "\\\\evil.example/x", "/..//evil.example",
|
||||
}
|
||||
for _, p := range payloads {
|
||||
got := SafeRedirectTarget(p)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(got)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(got, "/") && !strings.HasPrefix(got, "//"), "载荷 %q 逃逸为 %q", p, got)
|
||||
require.Empty(t, u.Host)
|
||||
require.Empty(t, u.Scheme)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user