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:
+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。
|
||||
|
||||
Reference in New Issue
Block a user