diff --git a/api/waf_vpconfig_api.go b/api/waf_vpconfig_api.go index d31b3d17..3ac6af37 100644 --- a/api/waf_vpconfig_api.go +++ b/api/waf_vpconfig_api.go @@ -140,27 +140,44 @@ func (w *WafVpConfigApi) UpdateManageTrustedProxiesApi(c *gin.Context) { response.FailWithMessage("解析请求失败", c) return } - // 校验每个条目是合法 CIDR 或 IP(留空=不信任任何代理头,允许) + // 校验每个条目是合法 CIDR / IP / private 关键字(留空=不信任任何代理头,允许) for _, entry := range strings.Split(req.TrustedProxies, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } - if strings.Contains(entry, "/") { - if _, _, err := net.ParseCIDR(entry); err != nil { + if !utils.IsValidManageTrustedProxyEntry(entry) { + if strings.Contains(entry, "/") { response.FailWithMessage(fmt.Sprintf("非法的CIDR: %s", entry), c) - return + } else { + response.FailWithMessage(fmt.Sprintf("非法的IP: %s", entry), c) } - } else if net.ParseIP(entry) == nil { - response.FailWithMessage(fmt.Sprintf("非法的IP: %s", entry), c) return } } if err := wafconfig.UpdateManageTrustedProxies(req.TrustedProxies); err != nil { response.FailWithMessage("更新管理端可信代理网段失败: "+err.Error(), c) - } else { - response.OkWithMessage("更新管理端可信代理网段成功", c) + return } + // 过宽的网段等于没有闸门,代理头不予采信、仍按网络层 IP 识别客户端,保存后明确告知一次 + if broad, entry := utils.ManageTrustedProxiesHasOverBroad(req.TrustedProxies); broad { + zlog.Warn(fmt.Sprintf("管理端可信代理网段包含过宽条目 %s:该条目只能放行闸门,代理头里的客户端IP不予采信,仍按网络层IP识别。容器部署请改填网关地址或 private", entry)) + response.OkWithMessage(fmt.Sprintf("更新成功,但 %s 过宽:这种网段无法用来判定代理头里的哪个IP是客户端,代理头将不被采信、仍按网络层IP识别(IP白名单与登录失败锁定也按它判定)。容器/内网部署请改填上游代理的地址或 private", entry), c) + return + } + response.OkWithMessage("更新管理端可信代理网段成功", c) +} + +// GetManageClientIPProbeApi 管理端「本次访问」真实IP诊断 +// @Summary 管理端真实IP诊断 +// @Description 回显本次请求的直连对端、可信代理判定、各代理头原始值与逐跳判定、最终采信的客户端IP及原因 +// @Tags 管理端配置 +// @Produce json +// @Success 200 {object} response.Response "获取成功" +// @Security ApiKeyAuth +// @Router /vipconfig/manageClientIpProbe [get] +func (w *WafVpConfigApi) GetManageClientIPProbeApi(c *gin.Context) { + response.OkWithDetailed(utils.TraceManageClientIP(c), "获取管理端真实IP诊断成功", c) } // GetManageCDNProviderApi 获取管理端引用的 CDN 厂商码 diff --git a/router/waf_vpconfig_router.go b/router/waf_vpconfig_router.go index 1cb9d41b..5a01f924 100644 --- a/router/waf_vpconfig_router.go +++ b/router/waf_vpconfig_router.go @@ -33,6 +33,10 @@ func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup) writeRouter.Use(middleware.RequireRole(enums.ROLE_SYSTEM_ADMIN)) writeRouter.POST("/api/v1/vipconfig/updateIpWhitelist", wafVpConfigApi.UpdateIpWhitelistApi) writeRouter.POST("/api/v1/vipconfig/updateManageTrustedProxies", wafVpConfigApi.UpdateManageTrustedProxiesApi) + // 只读诊断:仅回显本次请求自身的判定过程,不接受任何入参指定IP。 + // 放在系统管理员组:回显内容含代理头名(原本只在系统参数页可见)+可信网段, + // 凑齐即可推出"如何让自己在审计日志里显示成别的IP",不给审计/安全角色。 + writeRouter.GET("/api/v1/vipconfig/manageClientIpProbe", wafVpConfigApi.GetManageClientIPProbeApi) // CDN厂商快捷填充回源段(会触发对厂商官方端点的匿名拉取,故限系统管理员) writeRouter.GET("/api/v1/vipconfig/cdnProviderRanges", wafVpConfigApi.GetCDNProviderRangesApi) writeRouter.POST("/api/v1/vipconfig/updateManageCDNProvider", wafVpConfigApi.UpdateManageCDNProviderApi) diff --git a/utils/common.go b/utils/common.go index 553fbe44..5917b920 100644 --- a/utils/common.go +++ b/utils/common.go @@ -4,7 +4,6 @@ import ( "SamWaf/common/zlog" "SamWaf/global" "SamWaf/model" - "SamWaf/wafenginecore/clientip" "SamWaf/wafenginecore/ipset" "crypto/tls" "errors" @@ -19,8 +18,6 @@ import ( "strconv" "strings" "time" - - "github.com/gin-gonic/gin" ) // isPublicIP 判断 IP 是否为可安全对外访问的公网地址(用于防 SSRF)。 @@ -648,78 +645,3 @@ func IsIP(input string) bool { return net.ParseIP(input) != nil } -// GetManageClientIP 获取管理端客户端真实IP。 -// 安全默认:未配置代理头(GCONFIG_MANAGE_PROXY_HEADER 为空)时直接返回网络层 IP(c.RemoteIP())。 -// 即便配置了代理头,也仅当“直连对端 c.RemoteIP() 属于可信代理网段 GCONFIG_MANAGE_TRUSTED_PROXIES” -// 时才采信代理头;否则一律用网络层 IP。防止任意直连客户端伪造 X-Forwarded-For/X-Real-IP 绕过 -// 登录错误锁定 / 管理端 IP 白名单 / 令牌 IP 绑定。 -func GetManageClientIP(c *gin.Context) string { - remoteIP := c.RemoteIP() - // 未配置代理头 → 直接用网络层 IP - if global.GCONFIG_MANAGE_PROXY_HEADER == "" { - return remoteIP - } - // 配了代理头,但只信任来自“可信代理”的头;否则用网络层 IP - if !isTrustedManageProxy(remoteIP) { - return remoteIP - } - for _, header := range strings.Split(global.GCONFIG_MANAGE_PROXY_HEADER, ",") { - header = strings.TrimSpace(header) - if header == "" { - continue - } - val := c.GetHeader(header) - if val == "" { - continue - } - // 从右往左取第一个“非可信代理”的 IP:反向代理按追加语义 - // (nginx $proxy_add_x_forwarded_for) 把真实客户端 IP 追加在右侧、客户端伪造的值留在左侧, - // 故取最右侧的非可信 hop 才是真实客户端;逐个跳过可信代理链。取最左会取到伪造值。 - parts := strings.Split(val, ",") - for i := len(parts) - 1; i >= 0; i-- { - ip := strings.TrimSpace(parts[i]) - if !IsValidIPv4(ip) && !IsValidIPv6(ip) { - continue - } - if isTrustedManageProxy(ip) { - continue // 跳过可信代理 hop - } - return ip - } - } - return remoteIP -} - -// isTrustedManageProxy 判断直连对端 IP 是否落在管理端可信代理网段 -// GCONFIG_MANAGE_TRUSTED_PROXIES(CIDR 或单 IP,逗号分隔)内。 -// 留空 → 返回 false(不信任任何代理头,安全默认)。 -func isTrustedManageProxy(remoteIP string) bool { - // 管理端引用了某 CDN 厂商 → 直连对端属于该厂商中心库最新回源段即视为可信(自动跟随更新) - if global.GCONFIG_MANAGE_CDN_PROVIDER != "" && - clientip.IsProviderIP(global.GCONFIG_MANAGE_CDN_PROVIDER, strings.TrimSpace(remoteIP)) { - return true - } - if global.GCONFIG_MANAGE_TRUSTED_PROXIES == "" { - return false - } - ip := net.ParseIP(strings.TrimSpace(remoteIP)) - if ip == nil { - return false - } - for _, entry := range strings.Split(global.GCONFIG_MANAGE_TRUSTED_PROXIES, ",") { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - if strings.Contains(entry, "/") { - if _, ipnet, err := net.ParseCIDR(entry); err == nil && ipnet.Contains(ip) { - return true - } - continue - } - if single := net.ParseIP(entry); single != nil && single.Equal(ip) { - return true - } - } - return false -} diff --git a/utils/manageclientip.go b/utils/manageclientip.go new file mode 100644 index 00000000..38ca8dce --- /dev/null +++ b/utils/manageclientip.go @@ -0,0 +1,316 @@ +package utils + +import ( + "SamWaf/common/zlog" + "SamWaf/global" + "SamWaf/wafenginecore/clientip" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +// ManageTrustedProxyKeywordPrivate 可信代理网段支持的关键字:展开为内网+环回网段, +// 容器/内网部署填一个词即可,可与具体网段混写。 +const ManageTrustedProxyKeywordPrivate = "private" + +// maxManageProxyHops 单个代理头最多解析的跳数。代理链再长也到不了这个量级, +// 超过即视为畸形/构造流量,整头丢弃:避免超大头把逐跳结构撑成几十 MB(该逻辑在登录认证之前执行)。 +const maxManageProxyHops = 64 + +// maxManageProxyHeaderEcho 诊断回显的头原文长度上限 +const maxManageProxyHeaderEcho = 512 + +// maxManageProxyHopEcho 诊断回显的单跳原文长度上限(合法 IP 最长 45 字符,超出必是畸形值) +const maxManageProxyHopEcho = 64 + +// manageTrustedPrivateCIDRs private 关键字展开的网段(与手册、前端提示保持一致) +var manageTrustedPrivateCIDRs = func() []*net.IPNet { + list := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128", "fc00::/7"} + nets := make([]*net.IPNet, 0, len(list)) + for _, c := range list { + if _, n, err := net.ParseCIDR(c); err == nil { + nets = append(nets, n) + } + } + return nets +}() + +// 管理端真实IP的取值原因(诊断接口回显用) +const ( + ManageIPReasonNoProxyHeader = "no_proxy_header" // 未配代理头 → 网络层IP + ManageIPReasonPeerUntrusted = "peer_untrusted" // 直连对端不属可信代理 → 网络层IP + ManageIPReasonNoValidHeader = "no_valid_header" // 头缺失或无合法IP → 网络层IP + ManageIPReasonOverBroadGate = "overbroad_gate" // 对端只因过宽网段被判可信,头里无可验证结论 → 网络层IP + ManageIPReasonRightmostUntrusted = "rightmost_untrusted" // 从右往左第一个非可信hop + ManageIPReasonAllTrustedLeftmost = "all_trusted_leftmost" // 整条链都可信 → 取最左 +) + +// ManageClientIPHop 代理头里的一跳 +type ManageClientIPHop struct { + IP string `json:"ip"` + Valid bool `json:"valid"` + Trusted bool `json:"trusted"` + TrustedBy string `json:"trusted_by"` +} + +// ManageClientIPHeaderTrace 单个代理头的判定过程 +type ManageClientIPHeaderTrace struct { + Name string `json:"name"` + Value string `json:"value"` + Hops []ManageClientIPHop `json:"hops"` + Used bool `json:"used"` + TooLong bool `json:"too_long"` // 跳数超上限被整头丢弃 +} + +// ManageClientIPTrace 管理端真实IP的完整判定过程,供诊断接口回显 +type ManageClientIPTrace struct { + RemoteIP string `json:"remote_ip"` + ProxyHeader string `json:"proxy_header"` + TrustedProxies string `json:"trusted_proxies"` + CDNProvider string `json:"cdn_provider"` + PeerTrusted bool `json:"peer_trusted"` + PeerTrustedBy string `json:"peer_trusted_by"` + GateOverBroad bool `json:"gate_over_broad"` // 对端仅因过宽网段(如 0.0.0.0/0)被判可信 + Headers []ManageClientIPHeaderTrace `json:"headers"` + ClientIP string `json:"client_ip"` + Reason string `json:"reason"` +} + +// GetManageClientIP 获取管理端客户端真实IP。 +// 安全默认:未配置代理头(GCONFIG_MANAGE_PROXY_HEADER 为空)时直接返回网络层 IP(c.RemoteIP())。 +// 即便配置了代理头,也仅当"直连对端 c.RemoteIP() 属于可信代理网段 GCONFIG_MANAGE_TRUSTED_PROXIES" +// 时才采信代理头;否则一律用网络层 IP。防止任意直连客户端伪造 X-Forwarded-For/X-Real-IP 绕过 +// 登录错误锁定 / 管理端 IP 白名单 / 令牌 IP 绑定。 +func GetManageClientIP(c *gin.Context) string { + return TraceManageClientIP(c).ClientIP +} + +// TraceManageClientIP 与 GetManageClientIP 同一套判定,额外记录判定过程供诊断接口回显。 +// +// 取值分两轮,先要可验证的结论、拿不到才用不可验证的兜底: +// +// 第一轮:逐个头找"最右侧的非可信 hop"。反向代理按追加语义(nginx $proxy_add_x_forwarded_for) +// 把真实客户端 IP 追加在右侧、客户端伪造的值留在左侧,这个结论有代理链背书。 +// 第二轮:所有头的 hop 都落在可信网段内(可信网段覆盖到真实客户端时会这样,如内网管理员+内网网段), +// 此时取最左侧的链起点。这个结论与"客户端伪造了一个网段内的 IP"在协议上不可区分, +// 因此只在可信网段本身够窄时才用——网段宽到 0.0.0.0/0 就等于没有闸门,一律回退网络层 IP。 +func TraceManageClientIP(c *gin.Context) ManageClientIPTrace { + trace := ManageClientIPTrace{ + RemoteIP: c.RemoteIP(), + ProxyHeader: global.GCONFIG_MANAGE_PROXY_HEADER, + TrustedProxies: global.GCONFIG_MANAGE_TRUSTED_PROXIES, + CDNProvider: global.GCONFIG_MANAGE_CDN_PROVIDER, + } + trace.ClientIP = trace.RemoteIP + + if strings.TrimSpace(trace.ProxyHeader) == "" { + trace.Reason = ManageIPReasonNoProxyHeader + return trace + } + var narrowGate bool + trace.PeerTrusted, trace.PeerTrustedBy, narrowGate = trustManageProxy(trace.RemoteIP) + if !trace.PeerTrusted { + trace.Reason = ManageIPReasonPeerUntrusted + return trace + } + trace.GateOverBroad = !narrowGate + if !narrowGate { + // 可信网段宽到等于没有闸门:头里的任何值都与伪造不可区分,连读都不读,按网络层 IP 识别。 + // 注意不能只在下面第二轮拦——0.0.0.0/0 是 4 字节掩码,net.IPNet.Contains 对 IPv6 地址 + // 因长度不匹配恒为 false,异族的伪造值会被第一轮当成"非可信 hop"直接采信。 + trace.Reason = ManageIPReasonOverBroadGate + return trace + } + + for _, header := range strings.Split(trace.ProxyHeader, ",") { + header = strings.TrimSpace(header) + if header == "" { + continue + } + trace.Headers = append(trace.Headers, buildManageHeaderTrace(c, header)) + } + + // 第一轮:可验证的结论 + for i := range trace.Headers { + for j := len(trace.Headers[i].Hops) - 1; j >= 0; j-- { + hop := trace.Headers[i].Hops[j] + if hop.Valid && !hop.Trusted { + trace.Headers[i].Used = true + trace.ClientIP = normalizeIPText(hop.IP) + trace.Reason = ManageIPReasonRightmostUntrusted + return trace + } + } + } + // 第二轮:不可验证的兜底,闸门够窄才用 + for i := range trace.Headers { + for _, hop := range trace.Headers[i].Hops { + if !hop.Valid { + continue + } + trace.Headers[i].Used = true + trace.ClientIP = normalizeIPText(hop.IP) + trace.Reason = ManageIPReasonAllTrustedLeftmost + warnManageProxyChainAllTrusted() + return trace + } + } + // 所有头都缺失或无合法 IP + trace.Reason = ManageIPReasonNoValidHeader + return trace +} + +// buildManageHeaderTrace 解析单个代理头的逐跳可信判定 +func buildManageHeaderTrace(c *gin.Context, header string) ManageClientIPHeaderTrace { + raw := c.GetHeader(header) + headerTrace := ManageClientIPHeaderTrace{Name: header, Value: raw} + if len(headerTrace.Value) > maxManageProxyHeaderEcho { + headerTrace.Value = headerTrace.Value[:maxManageProxyHeaderEcho] + } + if raw == "" { + return headerTrace + } + // 先数逗号再切:超大头直接丢弃,不让 Split 先分配一遍 + if strings.Count(raw, ",") >= maxManageProxyHops { + headerTrace.TooLong = true + return headerTrace + } + for _, part := range strings.Split(raw, ",") { + ip := strings.TrimSpace(part) + hop := ManageClientIPHop{IP: ip} + if len(hop.IP) > maxManageProxyHopEcho { + hop.IP = hop.IP[:maxManageProxyHopEcho] + } + if IsValidIPv4(ip) || IsValidIPv6(ip) { + hop.Valid = true + // 只有足够具体的条目才能把某一跳认定成基础设施:过宽条目(如与窄条目混写的 0.0.0.0/0) + // 若参与 hop 判定,会把攻击者伪造的公网 IP 一并盖成"可信",反过来把它推给第二轮采信。 + if trusted, by, narrow := trustManageProxy(ip); trusted && narrow { + hop.Trusted, hop.TrustedBy = true, by + } + } + headerTrace.Hops = append(headerTrace.Hops, hop) + } + return headerTrace +} + +// normalizeIPText 把同一地址的多种写法归一(如 ::ffff:1.2.3.4 → 1.2.3.4), +// 避免下游按字符串比较的 IP 白名单/登录锁定计数/令牌IP绑定把同一个来源当成多个。 +func normalizeIPText(ip string) string { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return ip + } + if v4 := parsed.To4(); v4 != nil { + return v4.String() + } + return parsed.String() +} + +// trustManageProxy 判断 IP 是否属于管理端可信代理: +// 引用的 CDN 厂商回源段 ∪ GCONFIG_MANAGE_TRUSTED_PROXIES(CIDR / 单 IP / private 关键字,逗号分隔)。 +// 第三个返回值 narrow 表示命中的条目是否足够具体——过宽的条目(如 0.0.0.0/0)能放行闸门, +// 但不足以支撑第二轮那个不可验证的兜底结论。两者都为空 → 不信任任何代理头(安全默认)。 +func trustManageProxy(ip string) (trusted bool, by string, narrow bool) { + ip = strings.TrimSpace(ip) + if global.GCONFIG_MANAGE_CDN_PROVIDER != "" && clientip.IsProviderIP(global.GCONFIG_MANAGE_CDN_PROVIDER, ip) { + return true, "cdn:" + global.GCONFIG_MANAGE_CDN_PROVIDER, true + } + if global.GCONFIG_MANAGE_TRUSTED_PROXIES == "" { + return false, "", false + } + parsed := net.ParseIP(ip) + if parsed == nil { + return false, "", false + } + matched, matchedBy := false, "" + for _, entry := range strings.Split(global.GCONFIG_MANAGE_TRUSTED_PROXIES, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.EqualFold(entry, ManageTrustedProxyKeywordPrivate) { + for _, ipnet := range manageTrustedPrivateCIDRs { + if ipnet.Contains(parsed) { + return true, "private:" + ipnet.String(), true + } + } + continue + } + if strings.Contains(entry, "/") { + _, ipnet, err := net.ParseCIDR(entry) + if err != nil || !ipnet.Contains(parsed) { + continue + } + if !isOverBroadProxyCIDR(ipnet) { + return true, "cidr:" + entry, true + } + // 过宽条目先记下,继续找有没有更具体的条目也命中 + matched, matchedBy = true, "cidr:"+entry + continue + } + if single := net.ParseIP(entry); single != nil && single.Equal(parsed) { + return true, "ip:" + entry, true + } + } + return matched, matchedBy, false +} + +// isOverBroadProxyCIDR 判断网段是否宽到不足以充当"可信代理"的判据。 +// 阈值取到 IPv4 /4、IPv6 /3:真实部署里最宽的写法是 10.0.0.0/8 与 fc00::/7,都在阈值之内; +// 0.0.0.0/0、::/0 以及把全网拆成两半的 0.0.0.0/1 + 128.0.0.0/1 这类写法都会被判为过宽。 +func isOverBroadProxyCIDR(ipnet *net.IPNet) bool { + ones, bits := ipnet.Mask.Size() + if bits == 32 { + return ones <= 4 + } + return ones <= 3 +} + +// manageProxyAllTrustedWarnAt 兜底取值告警限频(unix 秒) +var manageProxyAllTrustedWarnAt int64 + +// warnManageProxyChainAllTrusted 走到第二轮兜底时提醒一次:这个取值无法与伪造区分。 +// 每 10 分钟最多一条,避免高频请求刷爆日志。 +func warnManageProxyChainAllTrusted() { + now := time.Now().Unix() + last := atomic.LoadInt64(&manageProxyAllTrustedWarnAt) + if now-last < 600 || !atomic.CompareAndSwapInt64(&manageProxyAllTrustedWarnAt, last, now) { + return + } + zlog.Warn("管理端代理头里的所有IP都落在可信代理网段内,已按代理链起点取真实客户端IP。该取值无法与客户端伪造区分,建议把 security.manage_trusted_proxies 收窄到上游代理自身的地址") +} + +// IsValidManageTrustedProxyEntry 校验可信代理网段的单个条目(CIDR / 单 IP / private 关键字) +func IsValidManageTrustedProxyEntry(entry string) bool { + entry = strings.TrimSpace(entry) + if entry == "" { + return true + } + if strings.EqualFold(entry, ManageTrustedProxyKeywordPrivate) { + return true + } + if strings.Contains(entry, "/") { + _, _, err := net.ParseCIDR(entry) + return err == nil + } + return net.ParseIP(entry) != nil +} + +// ManageTrustedProxiesHasOverBroad 判断可信代理网段里是否有过宽的条目(如 0.0.0.0/0、::/0), +// 返回命中的条目原文。这类条目只能放行闸门,不足以据此从代理头里认定真实客户端 IP。 +func ManageTrustedProxiesHasOverBroad(trustedProxies string) (bool, string) { + for _, entry := range strings.Split(trustedProxies, ",") { + entry = strings.TrimSpace(entry) + if entry == "" || !strings.Contains(entry, "/") { + continue + } + if _, ipnet, err := net.ParseCIDR(entry); err == nil && isOverBroadProxyCIDR(ipnet) { + return true, entry + } + } + return false, "" +} diff --git a/utils/manageclientip_probe_test.go b/utils/manageclientip_probe_test.go new file mode 100644 index 00000000..943ee207 --- /dev/null +++ b/utils/manageclientip_probe_test.go @@ -0,0 +1,56 @@ +package utils + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "SamWaf/global" + + "github.com/gin-gonic/gin" +) + +// TestProbeOverRealSocket 走真实 TCP 连接跑一遍三组配置:验证 gin 的 c.RemoteIP()、 +// 诊断接口的 JSON 字段名与取值链路端到端接得上(127.0.0.1 在这里扮演容器网关的角色)。 +func TestProbeOverRealSocket(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/probe", func(c *gin.Context) { c.JSON(200, TraceManageClientIP(c)) }) + srv := httptest.NewServer(r) + defer srv.Close() + + oldH, oldT, oldC := global.GCONFIG_MANAGE_PROXY_HEADER, global.GCONFIG_MANAGE_TRUSTED_PROXIES, global.GCONFIG_MANAGE_CDN_PROVIDER + defer func() { + global.GCONFIG_MANAGE_PROXY_HEADER, global.GCONFIG_MANAGE_TRUSTED_PROXIES, global.GCONFIG_MANAGE_CDN_PROVIDER = oldH, oldT, oldC + }() + global.GCONFIG_MANAGE_PROXY_HEADER = "CF-Connecting-IP" + global.GCONFIG_MANAGE_CDN_PROVIDER = "" + + cases := []struct{ name, trusted, wantIP, wantReason string }{ + {"配置A 0.0.0.0/0(过宽)", "0.0.0.0/0", "127.0.0.1", ManageIPReasonOverBroadGate}, + {"配置B 网关精确地址", "127.0.0.1", "1.2.3.4", ManageIPReasonRightmostUntrusted}, + {"配置C private 关键字", "private", "1.2.3.4", ManageIPReasonRightmostUntrusted}, + } + for _, cs := range cases { + global.GCONFIG_MANAGE_TRUSTED_PROXIES = cs.trusted + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/probe", nil) + req.Header.Set("CF-Connecting-IP", "1.2.3.4") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("[%s] 请求失败: %v", cs.name, err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var got ManageClientIPTrace + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("[%s] 解析失败: %v, body=%s", cs.name, err, body) + } + t.Logf("[%s] trusted=%-12s => client_ip=%-10s reason=%-22s peer=%s trusted_by=%q overbroad=%v", + cs.name, cs.trusted, got.ClientIP, got.Reason, got.RemoteIP, got.PeerTrustedBy, got.GateOverBroad) + if got.ClientIP != cs.wantIP || got.Reason != cs.wantReason { + t.Errorf("[%s] got %s/%s want %s/%s", cs.name, got.ClientIP, got.Reason, cs.wantIP, cs.wantReason) + } + } +} diff --git a/utils/manageclientip_test.go b/utils/manageclientip_test.go new file mode 100644 index 00000000..eac8e0cc --- /dev/null +++ b/utils/manageclientip_test.go @@ -0,0 +1,406 @@ +package utils + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "SamWaf/global" + "SamWaf/wafenginecore/clientip" + "SamWaf/wafenginecore/ipset" + + "github.com/gin-gonic/gin" +) + +func newManageCtx(remoteAddr string, headers map[string]string) *gin.Context { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + for k, v := range headers { + req.Header.Set(k, v) + } + c.Request = req + return c +} + +// withManageIPConfig 临时设置管理端真实IP相关的三个全局配置,跑完还原 +func withManageIPConfig(t *testing.T, proxyHeader, trustedProxies, cdnProvider string, fn func()) { + t.Helper() + oldHeader, oldTrusted, oldCDN := global.GCONFIG_MANAGE_PROXY_HEADER, global.GCONFIG_MANAGE_TRUSTED_PROXIES, global.GCONFIG_MANAGE_CDN_PROVIDER + defer func() { + global.GCONFIG_MANAGE_PROXY_HEADER, global.GCONFIG_MANAGE_TRUSTED_PROXIES, global.GCONFIG_MANAGE_CDN_PROVIDER = oldHeader, oldTrusted, oldCDN + }() + global.GCONFIG_MANAGE_PROXY_HEADER, global.GCONFIG_MANAGE_TRUSTED_PROXIES, global.GCONFIG_MANAGE_CDN_PROVIDER = proxyHeader, trustedProxies, cdnProvider + fn() +} + +// TestGetManageClientIP 管理端真实IP取值矩阵。 +// 前三组来自 issue #994:可信代理网段覆盖到真实客户端 IP 时,该 IP 不能被当作代理 hop 剔掉。 +func TestGetManageClientIP(t *testing.T) { + cases := []struct { + name string + proxyHeader string + trusted string + cdnProvider string + remoteAddr string + headers map[string]string + want string + wantReason string + }{ + { + // 0.0.0.0/0 等于没有闸门:头里的值与伪造不可区分,维持回退网络层IP(不因本次修正而变成可伪造) + name: "过宽网段(0.0.0.0/0):代理头不予采信,仍取网络层IP", + proxyHeader: "CF-Connecting-IP", + trusted: "0.0.0.0/0", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "172.17.0.1", + wantReason: ManageIPReasonOverBroadGate, + }, + { + name: "过宽网段(把全网拆两半):同样不予采信", + proxyHeader: "CF-Connecting-IP", + trusted: "0.0.0.0/1,128.0.0.0/1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "172.17.0.1", + wantReason: ManageIPReasonOverBroadGate, + }, + { + name: "精确网关可信+单值CF头", + proxyHeader: "CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "内网大段可信+客户端本身也在内网", + proxyHeader: "CF-Connecting-IP", + trusted: "10.0.0.0/8,172.16.0.0/12", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "10.1.2.3"}, + want: "10.1.2.3", + wantReason: ManageIPReasonAllTrustedLeftmost, + }, + { + name: "多跳XFF整条链都可信:取最左(链的起点)", + proxyHeader: "X-Forwarded-For", + trusted: "10.0.0.0/8,172.16.0.0/12", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": "10.1.2.3, 10.0.0.5"}, + want: "10.1.2.3", + wantReason: ManageIPReasonAllTrustedLeftmost, + }, + { + name: "多跳XFF右侧有非可信hop:取最右非可信(伪造值在左侧)", + proxyHeader: "X-Forwarded-For", + trusted: "10.0.0.0/8,172.16.0.0/12", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": "6.6.6.6, 1.2.3.4, 10.0.0.5"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "直连对端不可信:伪造代理头一律忽略,按网络层IP", + proxyHeader: "X-Forwarded-For", + trusted: "10.0.0.0/8", + remoteAddr: "203.0.113.9:5555", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4"}, + want: "203.0.113.9", + wantReason: ManageIPReasonPeerUntrusted, + }, + { + name: "可信代理网段留空:不信任任何代理头", + proxyHeader: "X-Forwarded-For", + trusted: "", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4"}, + want: "172.17.0.1", + wantReason: ManageIPReasonPeerUntrusted, + }, + { + name: "未配代理头:直接用网络层IP", + trusted: "0.0.0.0/0", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4"}, + want: "172.17.0.1", + wantReason: ManageIPReasonNoProxyHeader, + }, + { + name: "头缺失:回退网络层IP", + proxyHeader: "CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + want: "172.17.0.1", + wantReason: ManageIPReasonNoValidHeader, + }, + { + name: "头值全非法:回退网络层IP", + proxyHeader: "X-Forwarded-For", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": "unknown, not-an-ip"}, + want: "172.17.0.1", + wantReason: ManageIPReasonNoValidHeader, + }, + { + name: "多头按优先级:第一个头无值时看第二个", + proxyHeader: "X-Forwarded-For, CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "非法值夹在中间不影响取值", + proxyHeader: "X-Forwarded-For", + trusted: "10.0.0.0/8", + remoteAddr: "10.0.0.5:41234", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4, unknown, 10.0.0.5"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "IPv6单值头", + proxyHeader: "CF-Connecting-IP", + trusted: "fd00::/8", + remoteAddr: "[fd00::1]:41234", + headers: map[string]string{"CF-Connecting-IP": "2001:db8::1"}, + want: "2001:db8::1", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "private关键字:容器网关可信、真实IP照常取出", + proxyHeader: "CF-Connecting-IP", + trusted: "private", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "private关键字:公网对端不可信", + proxyHeader: "CF-Connecting-IP", + trusted: "private", + remoteAddr: "203.0.113.9:5555", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "203.0.113.9", + wantReason: ManageIPReasonPeerUntrusted, + }, + { + name: "private与具体网段混写", + proxyHeader: "CF-Connecting-IP", + trusted: "private, 203.0.113.9", + remoteAddr: "203.0.113.9:5555", + headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + // 上游 nginx 只写了 X-Real-IP、把客户端自带的 XFF 原样转发: + // XFF 里那个"落在可信网段内"的值不能抢在 X-Real-IP 前面被采信 + name: "首个头是上游未净化的XFF:不能劫持后面说真话的头", + proxyHeader: "X-Forwarded-For, X-Real-IP", + trusted: "10.0.0.0/8", + remoteAddr: "10.0.0.5:1234", + headers: map[string]string{"X-Forwarded-For": "10.0.0.99", "X-Real-IP": "203.0.113.7"}, + want: "203.0.113.7", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "跳数超上限的畸形头:整头丢弃,回退网络层", + proxyHeader: "X-Forwarded-For", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"X-Forwarded-For": strings.Repeat("1.2.3.4,", maxManageProxyHops+1) + "1.2.3.4"}, + want: "172.17.0.1", + wantReason: ManageIPReasonNoValidHeader, + }, + { + // 0.0.0.0/0 是 4 字节掩码,IPNet.Contains 对 IPv6 恒为 false: + // 异族的伪造值不能因此被当成"非可信 hop"直接采信 + name: "过宽网段 + 异族(IPv6)伪造头:仍不予采信", + proxyHeader: "X-Forwarded-For", + trusted: "0.0.0.0/0", + remoteAddr: "203.0.113.9:5555", + headers: map[string]string{"X-Forwarded-For": "2001:db8::dead"}, + want: "203.0.113.9", + wantReason: ManageIPReasonOverBroadGate, + }, + { + name: "过宽网段(::/0) + 异族(IPv4)伪造头:仍不予采信", + proxyHeader: "X-Forwarded-For", + trusted: "::/0", + remoteAddr: "[2001:db8::1]:5555", + headers: map[string]string{"X-Forwarded-For": "8.8.8.8"}, + want: "2001:db8::1", + wantReason: ManageIPReasonOverBroadGate, + }, + { + // 窄条目让闸门通过,但过宽条目不得给 hop 盖"可信"章, + // 否则伪造的公网 IP 会被推给第二轮当成链起点采信 + name: "窄+过宽混写:过宽条目不参与逐跳判定", + proxyHeader: "X-Forwarded-For", + trusted: "10.0.0.0/8,0.0.0.0/0", + remoteAddr: "10.0.0.5:1234", + headers: map[string]string{"X-Forwarded-For": "8.8.8.8, 203.0.113.7"}, + want: "203.0.113.7", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "IPv4-mapped 十六进制写法同样归一", + proxyHeader: "CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "::ffff:102:304"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "IPv6 大写/非压缩写法归一成小写压缩", + proxyHeader: "CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "2001:0DB8:0000:0000:0000:0000:0000:0001"}, + want: "2001:db8::1", + wantReason: ManageIPReasonRightmostUntrusted, + }, + { + name: "IPv4-mapped 写法归一成点分十进制", + proxyHeader: "CF-Connecting-IP", + trusted: "172.17.0.1", + remoteAddr: "172.17.0.1:41234", + headers: map[string]string{"CF-Connecting-IP": "::ffff:1.2.3.4"}, + want: "1.2.3.4", + wantReason: ManageIPReasonRightmostUntrusted, + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + withManageIPConfig(t, cs.proxyHeader, cs.trusted, cs.cdnProvider, func() { + trace := TraceManageClientIP(newManageCtx(cs.remoteAddr, cs.headers)) + if trace.ClientIP != cs.want { + t.Errorf("ClientIP = %q, want %q (reason=%s)", trace.ClientIP, cs.want, trace.Reason) + } + if trace.Reason != cs.wantReason { + t.Errorf("Reason = %q, want %q", trace.Reason, cs.wantReason) + } + if got := GetManageClientIP(newManageCtx(cs.remoteAddr, cs.headers)); got != trace.ClientIP { + t.Errorf("GetManageClientIP = %q, 与 TraceManageClientIP 不一致 %q", got, trace.ClientIP) + } + }) + }) + } +} + +// TestManageClientIPTraceFlags 诊断字段本身也要说得清:闸门过宽 / 头被整头丢弃 +func TestManageClientIPTraceFlags(t *testing.T) { + withManageIPConfig(t, "X-Forwarded-For", "0.0.0.0/0", "", func() { + trace := TraceManageClientIP(newManageCtx("203.0.113.9:5555", map[string]string{"X-Forwarded-For": "1.2.3.4"})) + if !trace.PeerTrusted || !trace.GateOverBroad { + t.Errorf("PeerTrusted=%v GateOverBroad=%v,应为 true/true", trace.PeerTrusted, trace.GateOverBroad) + } + if len(trace.Headers) != 0 { + t.Errorf("闸门过宽时不应解析任何头,实际 %+v", trace.Headers) + } + }) + withManageIPConfig(t, "X-Forwarded-For", "172.17.0.1", "", func() { + long := strings.Repeat("1.2.3.4,", maxManageProxyHops+1) + "1.2.3.4" + trace := TraceManageClientIP(newManageCtx("172.17.0.1:41234", map[string]string{"X-Forwarded-For": long})) + if len(trace.Headers) != 1 || !trace.Headers[0].TooLong || len(trace.Headers[0].Hops) != 0 { + t.Fatalf("超长头应标 TooLong 且不解析逐跳,实际 %+v", trace.Headers) + } + if len(trace.Headers[0].Value) > maxManageProxyHeaderEcho { + t.Errorf("回显原文应截断到 %d,实际 %d", maxManageProxyHeaderEcho, len(trace.Headers[0].Value)) + } + if trace.GateOverBroad { + t.Error("172.17.0.1 是具体条目,GateOverBroad 应为 false") + } + }) +} + +// TestManageClientIPCDNProvider 引用 CDN 厂商时:回源段内的对端可信、段外伪造不采信 +func TestManageClientIPCDNProvider(t *testing.T) { + old := clientip.GetProviderRanges("cloudflare") + defer clientip.SetProviderRanges("cloudflare", old) + clientip.SetProviderRanges("cloudflare", ipset.BuildMatchSet([]string{"103.21.244.0/22"})) + withManageIPConfig(t, "CF-Connecting-IP", "", "cloudflare", func() { + trace := TraceManageClientIP(newManageCtx("103.21.244.10:1234", map[string]string{"CF-Connecting-IP": "1.2.3.4"})) + if trace.ClientIP != "1.2.3.4" || trace.PeerTrustedBy != "cdn:cloudflare" { + t.Errorf("回源段内应取头 1.2.3.4,实际 %q by=%q", trace.ClientIP, trace.PeerTrustedBy) + } + if trace.GateOverBroad { + t.Error("CDN 回源段是具体条目,GateOverBroad 应为 false") + } + spoof := TraceManageClientIP(newManageCtx("8.8.8.8:1234", map[string]string{"CF-Connecting-IP": "1.2.3.4"})) + if spoof.ClientIP != "8.8.8.8" || spoof.Reason != ManageIPReasonPeerUntrusted { + t.Errorf("段外伪造应回退网络层,实际 %q reason=%q", spoof.ClientIP, spoof.Reason) + } + }) +} + +// TestManageClientIPTraceDetail 诊断信息本身要能说明"为什么取到这个值" +func TestManageClientIPTraceDetail(t *testing.T) { + withManageIPConfig(t, "X-Forwarded-For", "10.0.0.0/8", "", func() { + trace := TraceManageClientIP(newManageCtx("10.0.0.5:1234", map[string]string{"X-Forwarded-For": "6.6.6.6, 1.2.3.4, 10.0.0.5"})) + if !trace.PeerTrusted || trace.PeerTrustedBy != "cidr:10.0.0.0/8" { + t.Errorf("PeerTrusted=%v by=%q", trace.PeerTrusted, trace.PeerTrustedBy) + } + if len(trace.Headers) != 1 || !trace.Headers[0].Used || len(trace.Headers[0].Hops) != 3 { + t.Fatalf("Headers = %+v", trace.Headers) + } + hops := trace.Headers[0].Hops + if hops[0].Trusted || hops[1].Trusted || !hops[2].Trusted { + t.Errorf("逐跳可信判定不符: %+v", hops) + } + if trace.ClientIP != "1.2.3.4" || trace.Reason != ManageIPReasonRightmostUntrusted { + t.Errorf("ClientIP=%q reason=%q", trace.ClientIP, trace.Reason) + } + }) +} + +func TestManageTrustedProxiesHasOverBroad(t *testing.T) { + cases := []struct { + in string + want bool + entry string + }{ + {"0.0.0.0/0", true, "0.0.0.0/0"}, + {"0.0.0.0/1", true, "0.0.0.0/1"}, + {"fc00::/7", false, ""}, + {"::/0", true, "::/0"}, + {"10.0.0.0/8, 0.0.0.0/0", true, "0.0.0.0/0"}, + {"10.0.0.0/8,172.16.0.0/12", false, ""}, + {"private", false, ""}, + {"", false, ""}, + {"172.17.0.1", false, ""}, + } + for _, cs := range cases { + got, entry := ManageTrustedProxiesHasOverBroad(cs.in) + if got != cs.want || entry != cs.entry { + t.Errorf("ManageTrustedProxiesHasOverBroad(%q) = %v,%q want %v,%q", cs.in, got, entry, cs.want, cs.entry) + } + } +} + +func TestIsValidManageTrustedProxyEntry(t *testing.T) { + valid := []string{"", "private", "PRIVATE", "10.0.0.0/8", "172.17.0.1", "fc00::/7", "::1"} + invalid := []string{"10.0.0.0/33", "not-an-ip", "10.0.0.0/", "privatex"} + for _, v := range valid { + if !IsValidManageTrustedProxyEntry(v) { + t.Errorf("%q 应判为合法", v) + } + } + for _, v := range invalid { + if IsValidManageTrustedProxyEntry(v) { + t.Errorf("%q 应判为非法", v) + } + } +} diff --git a/waftask/task_config.go b/waftask/task_config.go index 822ce692..03a00340 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -8,6 +8,7 @@ import ( "SamWaf/model" "SamWaf/model/request" "SamWaf/service/waf_service" + "SamWaf/utils" "SamWaf/wafenginecore" "SamWaf/wafhostguard" "SamWaf/wafipban" @@ -735,7 +736,7 @@ func TaskLoadSetting(initLoad bool) { updateConfigStringItem(initLoad, "system", "record_log_type", global.GWAF_RUNTIME_RECORD_LOG_TYPE, "日志记录类型", "options", "all|全部,abnormal|非正常", configMap) updateConfigStringItem(initLoad, "system", "gwaf_proxy_header", global.GCONFIG_RECORD_PROXY_HEADER, "获取访客IP头信息(按照顺序)比如:X-Forwarded-For,X-Real-IP ,留空则提取的是直接访客IP", "string", "", configMap) - updateConfigStringItem(initLoad, "system", "gwaf_manage_proxy_header", global.GCONFIG_MANAGE_PROXY_HEADER, "管理端获取客户端IP头信息(按优先级逗号分隔,如 X-Forwarded-For,X-Real-IP,CF-Connecting-IP),留空则直接取网络IP。安全起见需配合 conf/config.yml 的 security.manage_trusted_proxies:仅当直连来源属可信代理时才采信此头", "string", "", configMap) + updateConfigStringItem(initLoad, "system", "gwaf_manage_proxy_header", global.GCONFIG_MANAGE_PROXY_HEADER, "管理端获取客户端IP头信息(按优先级逗号分隔,如 X-Forwarded-For,X-Real-IP,CF-Connecting-IP),留空则直接取网络IP。安全起见需配合 conf/config.yml 的 security.manage_trusted_proxies:仅当直连来源属可信代理时才采信此头(容器/内网部署可直接填 private)", "string", "", configMap) updateConfigIntItem(initLoad, "kafka", "kafka_enable", global.GCONFIG_RECORD_KAFKA_ENABLE, "kafka 是否激活", "int", "", configMap) updateConfigStringItem(initLoad, "kafka", "kafka_url", global.GCONFIG_RECORD_KAFKA_URL, "kafka url地址", "string", "", configMap) @@ -880,6 +881,12 @@ func TaskLoadSetting(initLoad bool) { // 提醒反向代理后的部署在 conf/config.yml 配置 security.manage_trusted_proxies, // 以免 IP白名单/登录锁定误按代理IP生效(该项放 config.yml 便于被白名单挡住时改文件+重启自救)。 if initLoad && global.GCONFIG_MANAGE_PROXY_HEADER != "" && global.GCONFIG_MANAGE_TRUSTED_PROXIES == "" { - zlog.Warn("管理端已配置代理头(gwaf_manage_proxy_header)但未设可信代理网段:出于安全,代理头将被忽略、按网络层IP识别客户端。若本机在反向代理之后,请在 conf/config.yml 填写 security.manage_trusted_proxies(如 10.0.0.0/8)后重启") + zlog.Warn("管理端已配置代理头(gwaf_manage_proxy_header)但未设可信代理网段:出于安全,代理头将被忽略、按网络层IP识别客户端。若本机在反向代理之后,请在 conf/config.yml 填写 security.manage_trusted_proxies(如 10.0.0.0/8,容器部署可填 private)后重启") + } + // 可信代理网段过宽 → 无法据此判定代理头里哪个 IP 是客户端,代理头不予采信。 + if initLoad { + if broad, entry := utils.ManageTrustedProxiesHasOverBroad(global.GCONFIG_MANAGE_TRUSTED_PROXIES); broad { + zlog.Warn("管理端可信代理网段包含过宽条目(" + entry + "):这种网段无法用来判定代理头里的哪个IP是客户端,代理头将不被采信、仍按网络层IP识别客户端(管理端IP白名单/登录失败锁定/令牌IP绑定也按它判定)。请在 conf/config.yml 把 security.manage_trusted_proxies 改成上游代理自身的地址,容器部署可填 private") + } } } diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index 162309e9..4218dec5 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -526,3 +526,44 @@ notes: Second, visitors who are already signed in must sign in once more after the upgrade. effect_off: Sites that do not have Website password access enabled are not affected at all. revert: Set session lifetime back to 1440, idle timeout back to 0 and keep bind to login IP enabled - that is the previous behaviour. + + - id: v1_3_25_manage_client_ip + version: v1.3.25 + kind: check + level: high + page: /sys/SystemConfig + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 检查「管理端可信代理网段」是否填得过宽 + detail: >- + 这项决定管理端把哪个 IP 当成真实客户端,而管理端 IP 白名单、登录失败锁定、令牌 IP 绑定、 + 登录历史与安全审计全部按它判定。本版本修正了一处取值错误:当网段填得比真实客户端 IP 还宽时 + (典型是填了 10.0.0.0/8 而管理员本身也在 10.x 内网访问),代理头里的真实 IP 会被当成代理节点跳过, + 最终记成上一跳地址。修正后这种情况按代理链起点取真实 IP,并且不会再抢在后面那个更可信的头前面下结论。 + 另外网段现在支持直接填关键字 private(展开为 10/8、172.16/12、192.168/16、环回与 fc00::/7), + 容器部署不必再去查网关地址。请顺手确认这里没有填 0.0.0.0/0 之类覆盖全网的写法: + 这种网段无法用来判断代理头里哪个 IP 才是客户端,程序会拒绝采信代理头、继续按网络层 IP 识别 + (容器里就是网关地址,所有人看起来是同一个 IP),启动日志里也会提示。 + effect_on: 填上游代理自身的地址(容器部署可直接填 private)后:管理端按真实客户端 IP 判定白名单与登录锁定,登录历史与安全审计记的也是真实来源。 + effect_off: 填 0.0.0.0/0 或留空:代理头不被采信,所有请求都记成代理机/网关的同一个地址——IP 白名单失去区分能力,一次爆破尝试就可能把所有人一起挡在门外。 + revert: 清空该配置即恢复为"只认直连 IP"。 + en: + title: Check whether the console trusted proxy ranges are set too wide + detail: >- + This setting decides which address the console treats as the real client, and the console IP allowlist, + login failure lockout, token IP binding, login history and security audit are all judged by it. This + version fixes a case where the value came out wrong: when the ranges were wider than the real client IP + (typically 10.0.0.0/8 while administrators themselves connect from 10.x), the real IP inside the proxy + header was skipped as a proxy hop and the previous hop was recorded instead. It now takes the start of + the proxy chain in that situation, and no longer jumps ahead of a later, more trustworthy header. The + ranges also accept the keyword private, which expands to 10/8, 172.16/12, 192.168/16, loopback and + fc00::/7, so container deployments need not look up the gateway address. While you are there, make sure + the value is not something like 0.0.0.0/0: a range that wide cannot tell which address in the proxy + header is the client, so the proxy header is not honoured at all and the network layer address is used + instead (the gateway address in a container, making every visitor look identical). The startup log points + this out as well. + effect_on: With the upstream proxy's own address (or simply private in containers), the console judges the allowlist and login lockout by the real client IP, and login history and security audit record the real source. + effect_off: With 0.0.0.0/0 or an empty value the proxy header is not honoured and every request is recorded as the same proxy or gateway address - the IP allowlist can no longer tell anyone apart, and a single brute-force burst can lock everyone out at once. + revert: Clearing the setting restores the "direct peer only" behaviour.