您的位置:  首页 > 技术杂谈 > 正文

如何获取客户端真实 IP?从 Gin 的一个 "Bug" 说起

2021-09-24 12:01 https://my.oschina.net/u/4545365/blog/5267971 askuygo 次阅读 条评论

作者:郑伟@石墨文档

1. 背景

求 IP 作为用户的身份标识属性之一,是一种非常重要的基础数据。在很多场景下,我们会基于客户端请求 IP 去做网络安全攻击防范或访问风险控制。通常我们可以通过 HTTP 协议 Request Headers 中 X-Forwarded-For 头来获取真实 IP。然而通过 X-Forwarded-For 头获取真实 IP 的方式真的可靠么?

2. 概念

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

前段时间石墨文档某 HTTP 服务升级 Gin 框架到 1.7.2 后突然发现一个 『Bug』,升级后服务端无法获正确的客户端 IP,取而代之的是 Kubernetes 集群中 Nginx Ingress IP。于是我们决定从 Gin 获取客户端相应源码来顺藤摸瓜排查一下。

业务方服务之前使用的是 v1.6.3 版本,我们先看看该版本 Context.ClientIP() 方法实现:

// ClientIP 方法可以获取到请求客户端的IPfunc (c *Context) ClientIP() string {   // 1. ForwardedByClientIP 默认为 true,此处会优先取 X-Forwarded-For 值,   // 如果 X-Forwarded-For 为空,则会再尝试取 X-Real-Ip   if c.engine.ForwardedByClientIP {      clientIP := c.requestHeader("X-Forwarded-For")      clientIP = strings.TrimSpace(strings.Split(clientIP, ",")[0])      if clientIP == "" {         clientIP = strings.TrimSpace(c.requestHeader("X-Real-Ip"))      }      if clientIP != "" {         return clientIP      }   }   // 2. 如果我们手动配置 ForwardedByClientIP 为 false 且 X-Appengine-Remote-Addr 不为空,则取 X-Appengine-Remote-Addr 作为客户端IP   if c.engine.AppEngine {      if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {         return addr      }   }   // 3. 最终才考虑取对端 IP 兜底   if ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)); err == nil {      return ip}   return ""}

再看 v1.7.2 版本, Contexnt.ClientIP() 方法实现:

func (c *Context) RemoteIP() (net.IP, bool) {   ...   remoteIP := net.ParseIP(ip) // 获取客户端 IP   ...   // trustedCIDRs 由 engine 启动时配置的 TrustedProxies 数组解析而来,表示可以信任的前置代理 CIDR 列表。只有配置了 engine.TrustedProxies 才有可能解析出正确的可信任 CIDR 列表。   // 只有 CIDR 列表不为空,这里才会将 remoteIP 和已配置可信 CIDR 列表进行比对。CIDR 列表中任一 CIDR 包含对端 IP,则将第二个返回值置为 true,表示对端 IP 可信任。   if c.engine.trustedCIDRs != nil {      for _, cidr := range c.engine.trustedCIDRs {         if cidr.Contains(remoteIP) {            return remoteIP, true         }      }   }   return remoteIP, false}func (c *Context) ClientIP() string {   // 1. AppEngine 默认为 false,如果应用通过 Google Cloud App Engine 部署,或用户手动设置为 true 且 X-Appengine-Remote-Addr 不为空,则会取 X-Appengine-Remote-Addr 值作为客户端 IP。   if c.engine.AppEngine {      if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {         return addr      }   }   // 2. 否则通过 RemoteIP() 方法判断对端 IP 是否可信,trusted 为 true 表示可信   // 详见上文 Context.RemoteIP() 方法内部注释。   remoteIP, trusted := c.RemoteIP()   if remoteIP == nil {      return ""   }   // 3. 如对端 IP 可信,且 ForwardedByClientIP 为 true(默认为 true),且   // RemoteIPHeaders 不为空(默认不为空),则根据 RemoteIPHeaders 中配置的获取 ClientIP 的 Headers 列表中依次获取。默认读取顺序:1. X-Forwarded-For;2. X-Real-IP。   if trusted && c.engine.ForwardedByClientIP && c.engine.RemoteIPHeaders != nil {      for _, headerName := range c.engine.RemoteIPHeaders {         // 对header进行处理,先通过","进行分割,并返回分割后 IP 列表的第一个合法 IP         ip, valid := validateHeader(c.requestHeader(headerName))         if valid {            return ip         }      }   }   // 3. 最终才考虑取对端 IP 兜底。   return remoteIP.String()}// validateHeader 会对入参header进行校验,先通过","进行分割成 IP 列表后,对每个 IP 进行合法性检查,如果任一 IP 不合法,则此Header不合法;否则返回 IP 列表中第一个 IP。func validateHeader(header string) (clientIP string, valid bool) {   if header == "" {      return "", false   }   items := strings.Split(header, ",")   for i, ipStr := range items {      ipStr = strings.TrimSpace(ipStr)      ip := net.ParseIP(ipStr)      ...      if i == 0 {         clientIP = ipStr         valid = true      }   }   return}

此 『Bug』详细讨论见:https://github.com/gin-gonic/gin/issues/2697。

3. 分析

先介绍几个稍后可能会涉及到的概念/术语:

$remote_addr:是 Nginx 与客户端进行 TCP 连接过程中,获得的客户端真实地址. Remote Address 无法伪造,因为建立 TCP 连接需要三次握手,如果伪造了源 IP,无法建立 TCP 连接,更不会有后面的 HTTP 请求。•X-Client-Real-IP:是一我们在云厂商 WAF/CDN 上自定义 Header,是由云厂商在边缘节点上设置的取值 $remote_addr  的 Header,可以保证我们获取到真实的客户端 IP。这个特性基本上绝大部分云厂商(阿里云、华为云、腾讯云等)都支持。

网络请求通常是浏览器(或其他客户端)发出请求,通过层层网络设备的转发,最终到达服务端。那么每一个环节收到请求中的 $remote_addr 必定是上游环节的真实 IP,这个无法伪造。那从全链路来看,如果需要最终请求的来源,则通过 X-Forwarded-For 来进行追踪,每一环节的 IP( $remote_addr )都添加到 X-Forwarded-For 字段之后,这样 X-Forwarded-For 就能串联全链路了。即:

X-Forwarded-For: client_ip, proxy1_ip, proxy2_ip

3.1. X-Forwarded-For 是否可以被伪造?

客户端是否能伪造 IP,取决于边缘节点(Edge Node)是如何处理 X-Forwarded-For 字段。客户端直接连接的首个 Proxy 节点都叫做边缘节点(Edge Node),无论是网关、CDN、LB 等,只要这一层是直接接入客户端访问的,那么它就是一个边缘节点。

•不重写 X-Forwarded-For 的边缘节点 边缘节点如果是透传 HTTP 的 X-Forwarded-For 头,那么它就是不安全的,客户端可以在 HTTP 请求中伪造 X-Forwarded-For 值,且这个值会被向后透传。

因此不重写 X-Forwarded-For 的边缘节点是不安全的边缘节点,用户可以伪造 X-Forwarded-For 。

# 不安全X-Forwareded-ForclientX-Forwarded-For(用户请求中的 X-Forwarded-For),proxy1proxy2proxy3...

•重写 X-Forwarded-For 的边缘节点 边缘节点如果重写 $remote_addr 到 X-Forwarded-For ,那么这就是安全的。边缘节点获取的 remote_addr 就是客户端的真实 IP。因此重写 X-Forwarded-For 的边缘节点是安全的边缘节点,用户无法伪造 X-Forwarded-For 。

# 边缘节点用 $remote_addr 来覆盖用户请求中的 X-Forwarded-For:proxy_set_header X-Forwarded-For $remote_addr; # 安全X-Forwareded-ForClientX-Forwarded-For(边缘节点获取的 remote_addr),proxy1proxy2proxy3...

3.2. 如何才能获取真实客户端 IP?

我们考虑公有云上常见网络拓扑结构下,能获取真实客户端 IP 的方案。

3.2.1. 客户端->WAF->SLB->Ingress->Pod

3.2.1.1. 使用 Nginx real-ip 模块

使用 Nginx real-ip 模块获取,需在 Ingress 上配置 proxy-real-ip-cidr ,把WAF 和 SLB(7 层) 地址都加上。操作后服务端使用 X-Forwarded-For 可取到真实 IP,通过 X-Original-Forwarded-For 可取到伪造 IP。

这种方案有如下缺点:

•由于 WAF 是云厂商维护,WAF 地址池众多,同时地址会有变化,维护此动态配置难度极大,如更新不及时会导致获取的客户端 IP 不准确。•即使采用此方案,业务方如果要使用新版本的 Gin 的 ctx. ClientIP() 方法,仍然需改动代码,将所有可信代理配置到 TrustedProxies,这会导致基础设施和业务服务耦合,这种方案显然是无法接受的,除非业务方愿意将依赖的 Gin 版本锁死在 v1.6.3。

3.2.1.2. 使用 WAF 自定义 Header

不少云厂商提供了自定义 Header 来获取客户端真实 IP( $remote_addr )能力,我们可以在云厂商 WAF 终端中提前配置好自定义 Header 头,比如 X-Appengine-Remote-Addr 或 X-Client-Real-IP 等,用来获取客户端真实 IP。

这种方案有如下缺点:

•如直接复用 X-Appengine-Remote-Addr 这个 Header,则需设置 engine. AppEngine=true,才可通过 ctx. ClientIP() 方法的前提下获取客户端 IP。•如使用其他 Header,比如 X-Client-Real-IP,则需要自行封装从 X-Client-Real-IP 中获取客户端 IP 方法,同时需要业务配合做改造。

架构大概如下所示:

3.2.2. 客户端->CDN->WAF->SLB->Ingress->Pod

3.2.2.2. 使用 real-ip

使用 real-ip 模块获取,需要在 ingress 上配置 proxy-real-ip-cidr 把 CDN、WAF 和 SLB(7 层)的地址都加上,服务端使用 X-Forwarded-For 可取到真实 IP,通过 X-Original-Forwarded-For 可取到伪造 IP。

此方案优缺点:

•此场景相比 3.2.1 多了层 CDN,CDN 地址池比 WAF 更大,地址池变化频率更高,同时厂商也没有提供 CDN 地址池,维护 Ingress 配置基本不可能。•即使采用此方案,业务方如果要使用新版本的 Gin 的 ctx. ClientIP() 方法,仍然需改动代码,将所有可信代理配置到 TrustedProxies,这会导致基础设施和业务服务耦合,这个肯定无法接受,除非业务方将 Gin 版本锁死在 1.6.3。

3.2.2.1. 使用 CDN 自定义 Header

此方案优缺点:同 3.1.1。架构大概如下所示:

3.2.3. 客户端->SLB->Ingress->Pod

可通过 Ingress 上设置 use-forwarded-headers 来防止 X-Forwarded-For 伪造。

•use-forwarded-headers=false

适用于 Ingress 前无代理层,例如直接挂在 4 层 SLB 上,ingress 默认重写 X-Forwarded-For 为 $remote_addr ,可防止伪造 X-Forwarded-For 。

•use-forwarded-headers=true

适用于 Ingress 前有代理层,例如 7 层 SLB 或 WAF、CDN 等相当于在 nginx.conf 中添加如下配置:

real_ip_header      X-Forwarded-For; real_ip_recursive   on; set_real_ip_from    0.0.0.0/0; // 默认信任所有 IP,无法避免伪造 X-Forwarded-For

架构大概如下所示:

4. 总结

从上文中我们不难看出,在云上复杂多变的网络拓扑结构下,我们会频繁地维护 CDN、WAF、SLB、Ingress 等多种网络设施配置。如果需完全保证 X-Forwarded-For 不可伪造,对于要升级 Gin 框架的 Go 服务来说,只有如下两种方案:

•继续尝试通过 X-Forwarded-For 获取客户端真实 IP。•尝试通过其他 Header 获取客户端真实 IP。

4.1. 继续尝试通过 X-Forwarded-For 获取客户端真实 IP

业务中需配置基础设施所有前置代理到 TrustedProxies 中,包含 CDN 地址池、WAF 地址池、Kunernetest Nginx Ingress 地址池,这种方案基本无法落地:

•配置太过复杂,一旦获取 IP 不准,很难排查。•导致业务配置和基础设施耦合,基础设施如果对 CDN、WAF、Ingress 做变动,业务代码必须同步变更。•部分可信代理 IP 根本没法配置,比如 CDN 地址池。

4.2. 尝试通过自定义 Header 获取客户端真实 IP

基础设施团队提供自定义 Header 来获取客户端真实 IP,如 X-Client-Real-IP 或 X-Appengine-Remote-Addr 。这种方案需要基础设施团队在云厂商 CDN 或 WAF 终端上做好相应的配置。这种方案:

•配置简单可靠,维护成本低,仅需在 CDN、WAF 终端配置自定义 Header 即可。•如果使用 X-Appengine-Remote-Addr,对于使用 Google Cloud 的 App Engine 的服务不需做任何修改。对于使用的国内云厂商的服务,则需要显式的配置 engine. AppEngine = true,然后继续通过 ctx.ClientIP() 方法即可。•如果使用其他自定义 Header,如 X-Client-Real-IP 来获取客户端真实 IP,建议可以考虑自行封装 ClientIP(*gin.Context) string 函数,从 X-Client-Real-IP 中获取客户端 IP。

 

资料链接:

  • https://datatracker.ietf.org/doc/html/rfc7239

  • https://github.com/gin-gonic/gin/issues/2697

  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接