Skip to content

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 模型 ​

信任链要拆成两段看:

  1. 直连场景(服务器直接对用户):真实 IP 就是 TCP 层的对端地址,也就是 RemoteAddr。此时 XFF 不存在或完全不可信。
  2. 反代/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)。