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

204 lines
7.9 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 (
"fmt"
"net"
"net/http"
"strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
// NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串),
// 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。
func NormalizeIP(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if h, _, err := net.SplitHostPort(s); err == nil {
s = h
}
ip := net.ParseIP(s)
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return v4.String()
}
return ip.String()
}
// LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。
// IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址),
// 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。
//
// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go),
// 否则会把整个 /64 段一并拉黑。
func LimitKey(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
// 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。
return ip
}
if v4 := parsed.To4(); v4 != nil {
return v4.String()
}
return parsed.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
// trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。
var trustedProxyNets []*net.IPNet
// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。
// 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。
//
// 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull
// 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。
func InitTrustedProxies() error {
trustedProxyNets = nil
for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
ipnet, err := parseTrustedProxy(item)
if err != nil {
return fmt.Errorf("parse trusted_proxies entry %q: %w", item, err)
}
trustedProxyNets = append(trustedProxyNets, ipnet)
}
return nil
}
// parseTrustedProxy 接受单 IP(自动补 /32 或 /128)或 CIDR。
func parseTrustedProxy(s string) (*net.IPNet, error) {
if ip := net.ParseIP(s); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
return &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}, nil
}
return &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}, nil
}
_, ipnet, err := net.ParseCIDR(s)
if err != nil {
return nil, err
}
return ipnet, nil
}
// isTrustedProxy 判断给定 IP 是否落在可信代理清单内。
func isTrustedProxy(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, n := range trustedProxyNets {
if n.Contains(ip) {
return true
}
}
return false
}
// headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。
//
// 只有两种情况成立:
// 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来;
// 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。
//
// 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。
// 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时,
// EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头,
// 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效,
// 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。
//
// 反过来说,只要要求「权威头只能由 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)
}
// cdnClientIPHeader 取请求里的 CDN 权威客户端 IP(Cloudflare 优先,其次腾讯 EdgeOne)。
// 只负责取值与规范化,不判断来源是否可信 —— 可信性由 headerSourceTrusted 负责。
func cdnClientIPHeader(r *http.Request) string {
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP
}
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
}
// (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。
parts := strings.Split(forwarded, ",")
for i := len(parts) - 1; i >= 0; i-- {
hop := NormalizeIP(parts[i])
if hop == "" {
continue
}
if !isTrustedProxy(hop) {
return hop
}
}
}
if realIP := NormalizeIP(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
}
return remote
}