如何獲取客戶端真實 IP?從 Gin 的一個 "Bug" 說起

語言: CN / TW / HK

作者:鄭偉@石墨文件

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