防止伪造x-forwarded-for中的IP

最近为开发的以太坊水龙头添加了根据IP地址进行限流的功能,在这过程中遇到并解决了X-Forwarded-For中的IP伪造的安全问题。

一般来说用户的IP可以通过与服务器建立TCP连接的客户端地址得到,如在Go中可以通过r.RemoteAddr获取,但为了方便对HTTPS证书、域名、负载均衡等进行相关配置,应用会运行在Nginx反向代理后,在此情况下获取到的连接地址是代理服务器的IP而非用户的。

我们可以使用 X-Forwarded-For 请求头来解应用部署在反向代理后无法获取真实IP的问题,每次代理服务器转发请求到下一个服务器时,要把代理服务器的 IP 写入 X-Forwarded-For 中,这样在最末端的应用服务收到请求时,就会得到一个 IP 列表:

X-Forwarded-For: client, proxy1, proxy2

X-Forwarded-For 是一个 HTTP 扩展头部。HTTP/1.1(RFC 2616)协议并没有对它的定义,它最开始是由 Squid 这个缓存代理软件引入,用来表示 HTTP 请求端真实 IP。如今它已经成为事实上的标准,被各大 HTTP 代理、负载均衡等转发服务广泛使用,并被写入 RFC 7239(Forwarded HTTP Extension)标准之中。

水龙头应用要支持无论是否部署在反向代理后都可以获取到用户IP,通常的做法是首先从HTTP头中获取 X-Forwarded-For ,如果该头存在就按逗号分隔取最左边第一个IP地址,不存在直接通过r.RemoteAddr获取IP地址,如realip包就是这样实现的: https://github.com/tomasen/realip/blob/master/realip.go#L53-L84

但这种方式存在IP伪造的风险,即如果应用没有部署在反向代理后,而用户在发送HTTP请求时指定了虚假的X-Forwarded-For 头,这时虽然应用没有运行在反向代理后但仍从X-Forwarded-For 头获取了用户伪造的中的IP。

最初想到的解决方法是判断r.RemoteAddr是否是内网地址,如果是再去解析 X-Forwarded-For 请求头来获取客户端的真实 IP,否则直接使用r.RemoteAddr作为用户的真实IP。当然也可以加入配置参数来指定配置是否部署在反向代理后。

但问题真的解决了吗?

我们刚才提到每层反向代理会依次将请求IP追加到 X-Forwarded-For 头中,因此我们的第一层反向代理会将用户伪造的 X-Forwarded-For 当作来自上一层反向代理的结果,然后将用户的真实IP追加到伪造的X-Forwarded-For 后,最终形成如下序列,这样应用拿到的X-Forwarded-For 中第一个IP依然是用户伪造的数据。

X-Forwarded-For: illegalIp, client, proxy1, proxy2

最终我参考了Koa.js和Egg.js中的前置代理模式,即通过 maxIpsCount 来配置前置的反向代理数量,这样在获取请求真实IP地址时,就会忽略掉用户多传递的伪造IP地址了。 https://github.com/koajs/koa/blob/master/docs/api/request.md#requestips

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  get ips () {
    const proxy = this.app.proxy
    const val = this.get(this.app.proxyIpHeader)
    let ips = proxy && val
      ? val.split(/\s*,\s*/)
      : []
    if (this.app.maxIpsCount > 0) {
      ips = ips.slice(-this.app.maxIpsCount)
    }
    return ips
  }

使用Go实现的版本如下所示,与Koa.js中的区别是当反向代理数量不大于0时则使用r.RemoteAddr中的IP,这样就支持了使用反向代理与否的两种场景,且避免了用户伪造X-Forwarded-For。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func getClientIPFromRequest(proxyCount int, r *http.Request) string {
	if proxyCount > 0 {
		xForwardedFor := r.Header.Get("X-Forwarded-For")
		xRealIP := r.Header.Get("X-Real-Ip")

		if xForwardedFor != "" {
			xForwardedForParts := strings.Split(xForwardedFor, ",")
			// Avoid reading the user's forged request header by configuring the count of reverse proxies
			partIndex := len(xForwardedForParts) - proxyCount
			if partIndex < 0 {
				partIndex = 0
			}
			return strings.TrimSpace(xForwardedForParts[partIndex])
		}

		if xRealIP != "" {
			return strings.TrimSpace(xRealIP)
		}
	}

	remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		remoteIP = r.RemoteAddr
	}
	return remoteIP
}

此外还了解到了另一个方案,即在第一层反向代理重置X-Forwarded-For头,但该水龙头应用需要适配HeroKu,Herou也是使用X-Forwarded-For获取用户IP,但其无法像Nginx那样重置HTTP header,因此上述配置反向代理的数量是一种更通用的方式。