Appearance
X-Forwarded-For 能伪造吗?地域风控是怎么被绕过的(Go 防御实现)
一个做地区限购的站点,后端用 X-Forwarded-For 判断用户城市,上线没两天就被人用脚本刷穿了:同一个 IP 换了十几个城市,绕过限购规则。排查半天,问题不在规则,而在取 IP 这一步——X-Forwarded-For 是 HTTP 请求头,客户端想填什么填什么,你拿它当可信依据,等于把门锁钥匙交给小偷自己配。
这类问题比想象中普遍:IP 白名单后台、投票防刷、地域限制访问、异地登录风控,凡是「拿 XFF 当真实 IP」的实现,都存在同样一个洞。这篇把原理讲透,再给一个 Go 中间件:怎么在可信代理边界之外正确取真实 IP,并用 IP 归属地做地域判断,让伪造在第一步就失效。
一、XFF 到底能不能伪造
X-Forwarded-For 的格式是一串 IP,从右往左依次是「客户端 → 各层代理」的原始 IP 链:
X-Forwarded-For: 客户端IP, 代理1, 代理2它的作用只有一个:让后面的服务知道请求最初从哪来。问题在于,它本身不经过任何校验。客户端发来的原始请求里,X-Forwarded-For: 114.114.114.114 这种头是随便拼的。很多服务器直接读这个头、取第一个 IP 就当成用户 IP,于是攻击者一个 curl 就能伪装成任何地区:
bash
curl -H "X-Forwarded-For: 125.32.0.1" https://你的站点/api/领券更隐蔽的是追加伪造。有一次代理加在了请求链上,会把转发方看到的 XFF 原样保留再追加自己的 IP,结果客户端已经填了假 IP,代理又追了一笔,服务器取「第一个 IP」就取到了攻击者编的地址。链越长,你越难分清哪个是编的、哪个是代理加的。
核心结论:XFF 只有在「由你信任的代理层写入」这个前提下才有可信度,从客户端直接进来的 XFF 一个字节都不能信。
二、正确的取 IP 模型
信任链要拆成两段看:
- 直连场景(服务器直接对用户):真实 IP 就是 TCP 层的对端地址,也就是
RemoteAddr。此时 XFF 不存在或完全不可信。 - 反代/CDN 场景(Nginx、Cloudflare、云 CDN 在前面):你的服务只跟代理通信,对端地址是代理的 IP。真实 IP 在代理写入的头里(XFF 或专门的回源头),但必须只解析「可信代理范围」右侧第一个地址——也就是移除所有可信代理追加的部分之后,最右剩下的那个 IP 才是原始客户端。
一句话:防御的边界在「最后一跳可信代理」。你只需要知道「哪些 IP 是可信代理」,其余地址一律视作客户端伪造。解析规则是:
- 从 XFF 最右边开始往左;
- 遇到可信代理网段内的 IP 就跳过;
- 遇到的第一个不在可信网段的 IP,就是真实客户端 IP;
- 如果整个链都在可信网段内,说明代理直接面对客户端,用 RemoteAddr。
配合 Nginx 一侧等价的做法是 real_ip 模块:声明 set_real_ip_from 可信代理网段,再指定 real_ip_header X-Forwarded-For。云 CDN 场景同理,用 CDN 提供的回源头并配置好节点网段,不要裸读 XFF。
三、Go 中间件:取真实 IP + IP 归属地地域判断
下面这段代码是一个完整可运行的 Go HTTP 服务:中间件从请求里解出真实 IP(支持可信代理白名单),再调 https://ip9.com.cn/get?ip=xxx 查归属地,按「国家/省份 → 白名单/黑名单」给出放行或拦截结论。标准库实现,无第三方依赖。
go
package main
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
)
// 可信代理网段:配置成你前面的 Nginx / CDN 节点段,其余一律不信
var trustedProxies = []string{"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12"}
var trustedNets []*net.IPNet
func init() {
for _, cidr := range trustedProxies {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
log.Fatal(err)
}
trustedNets = append(trustedNets, n)
}
}
func isTrusted(ip net.IP) bool {
for _, n := range trustedNets {
if n.Contains(ip) {
return true
}
}
return false
}
// realClientIP 取真实客户端 IP:直连用 RemoteAddr;反代场景从 XFF 右往左
// 剥离可信代理,取第一个不可信地址
func realClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
remote := net.ParseIP(host)
if remote == nil || !isTrusted(remote) {
return host // 直连(或对端不在可信列表),对端地址就是真实 IP
}
// 对端是可信代理:解析 XFF,从右往左找第一个不可信 IP
xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
if xff == "" {
return host // 代理没带 XFF,只能退回对端地址
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !isTrusted(ip) {
return ip.String()
}
}
return host
}
// Geo IP9 接口 data 层关键字段
type Geo struct {
IP string `json:"ip"`
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
}
type GeoResp struct {
Ret int `json:"ret"`
Data Geo `json:"data"`
}
// 带简单缓存 + 限速的归属地查询,避免频繁打接口
var cache = map[string]Geo{}
var lastQuery time.Time
func queryGeo(ip string) (Geo, error) {
if g, ok := cache[ip]; ok {
return g, nil
}
// 免费版限速 60 次/分钟,这里限制为每秒 1 次
if wait := time.Second - time.Since(lastQuery); wait > 0 {
time.Sleep(wait)
}
client := http.Client{Timeout: 6 * time.Second}
resp, err := client.Get("https://ip9.com.cn/get?ip=" + ip)
if err != nil {
return Geo{}, err
}
defer resp.Body.Close()
lastQuery = time.Now()
var gr GeoResp
if err := json.NewDecoder(resp.Body).Decode(&gr); err != nil {
return Geo{}, err
}
if gr.Ret != 200 {
return Geo{}, fmt.Errorf("geo api ret=%d", gr.Ret)
}
cache[ip] = gr.Data
return gr.Data, nil
}
// 地域规则:演示用,改成你业务的省级白名单;nil 表示放行
var provBlacklist = map[string]bool{}
func main() {
http.HandleFunc("/geo", func(w http.ResponseWriter, r *http.Request) {
ip := realClientIP(r)
g, err := queryGeo(ip)
if err != nil {
http.Error(w, "geo query failed: "+err.Error(), http.StatusBadGateway)
return
}
denied := provBlacklist[g.Prov] || g.Country != "中国"
decision := "allow"
if denied {
decision = "deny"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"real_ip": ip,
"province": g.Prov,
"city": g.City,
"isp": g.ISP,
"decision": decision,
})
})
fmt.Println("listen :8080, 测试: curl 'http://127.0.0.1:8080/geo'")
log.Fatal(http.ListenAndServe(":8080", nil))
}跑起来后,直接访问会返回你本机的真实 IP 和归属地。重点看两个行为:
- 不加 XFF:
RemoteAddr是 127.0.0.1,在可信网段内,中间件去解析 XFF,没有就退回 127.0.0.1,查出来归属地是本机; - 伪造 XFF:
curl -H "X-Forwarded-For: 125.32.0.1" http://127.0.0.1:8080/geo——因为对端 127.0.0.1 是可信代理,攻击者编的 125.32.0.1 不在可信网段,会被当成真实 IP 拿去查归属地。这说明什么?本代码在「模拟可信代理在中间」的场景里是对的:真实代理会把客户端原始 XFF 追加进来,追到最后一个不入可信网段的就是客户端。但如果你的服务直连公网,对端 IP 不在可信列表,XFF 直接被忽略,伪造头完全无效——这就是最终的形态:可信网段配得越严,伪造越没戏。
Gunicorn/Uvicorn 后面的 Python Web 应用、Nginx 反代里的 PHP-FPM,思路一模一样:先确定可信代理边界,再解析。别在业务代码里直接 header('X-Forwarded-For') 一把梭。
四、落地注意事项
可信网段要配全。 代理可能有多层(云 WAF → CDN → Nginx),每一层的网段都要进白名单,漏一层就会把代理 IP 误当用户 IP。反过来,白名单宁缺毋滥:宁可不支持多级代理,也不要把客户端 IP 段加进来。
记得校验 IP 合法性。 XFF 里可能塞 abc、塞 IPv6、塞一堆逗号,解析时每个段都要能过 net.ParseIP,解析不了的直接跳过而不是报错放弃整条链。
IPv6 要一起处理。 加了 IPv6 的客户端,XFF 里会出现 ::1、带作用域的地址,SplitHostPort 和 ParseIP 都要兼容。云 CDN 开启 IPv6 回源后尤其常见。
归属地查询要限速和缓存。 每个请求都查一遍接口,免费额度撑不住。上面的代码做了「进程内缓存 + 每秒 1 次限速」,生产环境可以把缓存换成 Redis 或本地 LRU,TTL 设 1-24 小时(IP 归属地一般不会频繁变动,详见站内《IP 归属地数据多久更新一次》)。IP9 免费版 60 次/分钟/IP,够一个中小站点用了;量再大考虑 VIP 版(无限制配额、SLA 99.95%)。
别把所有安全都押在 IP 上。 IP 只是风控的一个维度:共享出口 IP(公司、学校、网吧)会误伤,动态拨号会漂移。地域判断适合做「规则前置」——快速拦掉明显异常的,深挖留给行为特征(设备指纹、频次、历史)。
总结
X-Forwarded-For 可伪造是 HTTP 协议层面的既定事实,防它的办法不是「更聪明的解析」,而是「更严的信任边界」:只信最后一跳可信代理,其余全是噪音。拿 Go 写中间件,配好可信网段、取真实 IP、查归属地做地域规则,伪造请求在第一步就拿不到你想要的「假身份」。这套东西本质是给所有「根据 IP 做决策」的功能打底——地域限制、异地登录风控、领券防刷,都先把 IP 取对,再谈规则。查任意 IP 归属地可以直接用 https://ip9.com.cn/get?ip=xxx(官网 https://www.ip9.com.cn,免注册、支持 IPv4/IPv6)。