直接用c.clientip()会出错,因其默认只信任127.0.0.1,线上经nginx/cdn后返回内网地址而非真实ip,且不校验x-forwarded-for等头是否来自可信代理,易被伪造绕过;正确做法是先调用settrustedproxies显式配置可信代理网段,再结合net.parsecidr与atomic.value实现支持cidr和热更新的ip白名单中间件。

直接用 c.ClientIP() 会出错
很多新手一上来就写 c.ClientIP() 做判断,结果在 Nginx、CDN 或云厂商 SLB 后面时,拿到的全是 127.0.0.1 或内网地址(比如 10.0.0.3),根本不是用户真实 IP。这是因为 ClientIP() 默认只按固定顺序读 X-Forwarded-For、X-Real-IP 等头,但不验证这些头是否来自可信代理——攻击者随便加个 X-Forwarded-For: 1.2.3.4 就能绕过。
正确做法是:先调用 r.SetTrustedProxies() 显式声明你信任哪些代理网段(比如 Nginx 所在的 10.0.0.0/8 或 127.0.0.1/32),再让 Gin 按 RFC 7239 规则安全地解析真实 IP。否则中间件里所有基于 IP 的逻辑都不可信。
IPWhitelist 中间件必须支持 CIDR 和单 IP
硬编码写死 "192.168.1.100" 这种字符串匹配,既不能覆盖子网(比如整个办公网 192.168.1.0/24),也无法处理 IPv6。实际部署中,白名单往往是一组网段 + 若干独立 IP。
- 用
net.ParseCIDR(ip)解析形如"192.168.1.0/24"的网段 - 对纯 IP(如
"2001:db8::1")手动补掩码:/32(IPv4)或/128(IPv6) - 把解析结果存进
[]*net.IPNet,后续用ipNet.Contains(clientIP)判断
示例关键片段:
func IPWhitelist(ips []string) gin.HandlerFunc {
parsed := make([]*net.IPNet, 0, len(ips))
for _, ip := range ips {
if _, net, err := net.ParseCIDR(ip); err == nil {
parsed = append(parsed, net)
} else if ipAddr := net.ParseIP(ip); ipAddr != nil {
mask := net.CIDRMask(32, 32)
if ipAddr.To4() == nil {
mask = net.CIDRMask(128, 128)
}
parsed = append(parsed, &net.IPNet{IP: ipAddr, Mask: mask})
}
}
return func(c *gin.Context) {
clientIP := net.ParseIP(c.ClientIP())
if clientIP == nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid ip"})
return
}
allowed := false
for _, ipNet := range parsed {
if ipNet.Contains(clientIP) {
allowed = true
break
}
}
if !allowed {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip not allowed"})
return
}
c.Next()
}
}
生产环境必须配合 SetTrustedProxies 使用
不设可信代理,c.ClientIP() 就是纸糊的。常见错误包括:
- 传空切片:
r.SetTrustedProxies([]string{})—— 相当于没设,仍走默认不安全逻辑 - 只写
"127.0.0.1"却忘了 Docker 容器内网地址(如"172.17.0.0/16") - 用
"0.0.0.0/0"试图“全信”,这等于彻底放弃防护
正确姿势是查清你实际经过的代理链 IP 段,例如:
r := gin.New()
r.SetTrustedProxies([]string{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12"})
r.Use(IPWhitelist([]string{"192.168.1.0/24", "2001:db8::/64"}))
注意:SetTrustedProxies 必须在注册任何中间件前调用,否则无效。
动态更新黑白名单要靠 atomic.Value
如果白名单要从配置中心或数据库热加载,别每次请求都去读文件或查 DB——性能会崩。标准做法是用 atomic.Value 存一个 []*net.IPNet,更新时原子替换整个切片。
容易忽略的点:
- 更新时不能原地修改旧切片(
append可能触发底层数组扩容,导致部分 goroutine 还在读旧数据) - 解析失败的规则项必须跳过,不能 panic,否则整个中间件挂掉
- 首次加载失败应 fallback 到空白名单(拒绝全部),而不是放行全部
真正上线时,IP 过滤只是第一道门;后面还得接限流、认证、WAF 规则。单独靠 IP 黑白名单防不了伪造头、代理穿透或内部横向移动——它只解决“谁可以敲门”,不解决“敲门后干了什么”。











