如何獲取客戶端真實 IP?從 Gin 的一個 "Bug" 說起
1. 背景
請求 IP 作為用戶的身份標識屬性之一,是一種非常重要的基礎數(shù)據(jù)。在很多場景下,我們會基于客戶端請求 IP 去做網(wǎng)絡安全攻擊防范或訪問風險控制。通常我們可以通過 HTTP 協(xié)議 Request Headers 中 X-Forwarded-For 頭來獲取真實 IP。然而通過 X-Forwarded-For 頭獲取真實 IP 的方式真的可靠么?
2. 概念
X-Forwarded-For 是一個 HTTP 擴展頭。HTTP/1.1(RFC 2616)標準中并沒有對它的定義,它最開始是由 Squid 這個緩存代理軟件引入,用來表示 HTTP 請求端真實 IP,現(xiàn)在已經(jīng)成為事實上的標準,被各大 HTTP 代理、負載均衡等轉(zhuǎn)發(fā)服務廣泛使用,并被寫入 RFC 7239(Forwarded HTTP Extension)標準之中。
前段時間石墨文檔某 HTTP 服務升級 Gin 框架到 1.7.2 后突然發(fā)現(xiàn)一個 『Bug』,升級后服務端無法獲正確的客戶端 IP,取而代之的是 Kubernetes 集群中 Nginx Ingress IP。于是我們決定從 Gin 獲取客戶端相應源碼來順藤摸瓜排查一下。
業(yè)務方服務之前使用的是 v1.6.3 版本,我們先看看該版本 Context.ClientIP() 方法實現(xiàn):
// ClientIP 方法可以獲取到請求客戶端的IPfunc (c *Context) ClientIP() string {// 1. ForwardedByClientIP 默認為 true,此處會優(yōu)先取 X-Forwarded-For 值,// 如果 X-Forwarded-For 為空,則會再嘗試取 X-Real-Ipif 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 作為客戶端IPif 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() 方法實現(xiàn):
func (c *Context) RemoteIP() (net.IP, bool) {...remoteIP := net.ParseIP(ip) // 獲取客戶端 IP...// trustedCIDRs 由 engine 啟動時配置的 TrustedProxies 數(shù)組解析而來,表示可以信任的前置代理 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() 方法內(nèi)部注釋。remoteIP, trusted := c.RemoteIP()if remoteIP == nil {return ""}// 3. 如對端 IP 可信,且 ForwardedByClientIP 為 true(默認為 true),且// RemoteIPHeaders 不為空(默認不為空),則根據(jù) 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 列表的第一個合法 IPip, valid := validateHeader(c.requestHeader(headerName))if valid {return ip}}}// 3. 最終才考慮取對端 IP 兜底。return remoteIP.String()}// validateHeader 會對入?yún)eader進行校驗,先通過","進行分割成 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 = ipStrvalid = 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,是由云廠商在邊緣節(jié)點上設置的取值 $remote_addr 的 Header,可以保證我們獲取到真實的客戶端 IP。這個特性基本上絕大部分云廠商(阿里云、華為云、騰訊云等)都支持。
網(wǎng)絡請求通常是瀏覽器(或其他客戶端)發(fā)出請求,通過層層網(wǎng)絡設備的轉(zhuǎn)發(fā),最終到達服務端。那么每一個環(huán)節(jié)收到請求中的 $remote_addr 必定是上游環(huán)節(jié)的真實 IP,這個無法偽造。那從全鏈路來看,如果需要最終請求的來源,則通過 X-Forwarded-For 來進行追蹤,每一環(huán)節(jié)的 IP( $remote_addr )都添加到 X-Forwarded-For 字段之后,這樣 X-Forwarded-For 就能串聯(lián)全鏈路了。即:
X-Forwarded-For: client_ip, proxy1_ip, proxy2_ip3.1. X-Forwarded-For 是否可以被偽造?
客戶端是否能偽造 IP,取決于邊緣節(jié)點(Edge Node)是如何處理 X-Forwarded-For 字段。客戶端直接連接的首個 Proxy 節(jié)點都叫做邊緣節(jié)點(Edge Node),無論是網(wǎng)關、CDN、LB 等,只要這一層是直接接入客戶端訪問的,那么它就是一個邊緣節(jié)點。
?不重寫 X-Forwarded-For 的邊緣節(jié)點 邊緣節(jié)點如果是透傳 HTTP 的 X-Forwarded-For 頭,那么它就是不安全的,客戶端可以在 HTTP 請求中偽造 X-Forwarded-For 值,且這個值會被向后透傳。
因此不重寫 X-Forwarded-For 的邊緣節(jié)點是不安全的邊緣節(jié)點,用戶可以偽造 X-Forwarded-For 。
# 不安全X-Forwareded-For:clientX-Forwarded-For(用戶請求中的 X-Forwarded-For),proxy1,proxy2,proxy3...
?重寫 X-Forwarded-For 的邊緣節(jié)點 邊緣節(jié)點如果重寫 $remote_addr 到 X-Forwarded-For ,那么這就是安全的。邊緣節(jié)點獲取的 remote_addr 就是客戶端的真實 IP。因此重寫 X-Forwarded-For 的邊緣節(jié)點是安全的邊緣節(jié)點,用戶無法偽造 X-Forwarded-For 。
# 邊緣節(jié)點用 $remote_addr 來覆蓋用戶請求中的 X-Forwarded-For:proxy_set_header X-Forwarded-For $remote_addr;# 安全X-Forwareded-For:ClientX-Forwarded-For(邊緣節(jié)點獲取的 remote_addr),proxy1,proxy2,proxy3...
3.2. 如何才能獲取真實客戶端 IP?
我們考慮公有云上常見網(wǎng)絡拓撲結(jié)構(gòu)下,能獲取真實客戶端 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 地址池眾多,同時地址會有變化,維護此動態(tài)配置難度極大,如更新不及時會導致獲取的客戶端 IP 不準確。?即使采用此方案,業(yè)務方如果要使用新版本的 Gin 的 ctx. ClientIP() 方法,仍然需改動代碼,將所有可信代理配置到 TrustedProxies,這會導致基礎設施和業(yè)務服務耦合,這種方案顯然是無法接受的,除非業(yè)務方愿意將依賴的 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 方法,同時需要業(yè)務配合做改造。
架構(gòu)大概如下所示:

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。
此方案優(yōu)缺點:
?此場景相比 3.2.1 多了層 CDN,CDN 地址池比 WAF 更大,地址池變化頻率更高,同時廠商也沒有提供 CDN 地址池,維護 Ingress 配置基本不可能。?即使采用此方案,業(yè)務方如果要使用新版本的 Gin 的 ctx. ClientIP() 方法,仍然需改動代碼,將所有可信代理配置到 TrustedProxies,這會導致基礎設施和業(yè)務服務耦合,這個肯定無法接受,除非業(yè)務方將 Gin 版本鎖死在 1.6.3。
3.2.2.1. 使用 CDN 自定義 Header
此方案優(yōu)缺點:同 3.1.1。架構(gòu)大概如下所示:

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
架構(gòu)大概如下所示:

4. 總結(jié)
從上文中我們不難看出,在云上復雜多變的網(wǎng)絡拓撲結(jié)構(gòu)下,我們會頻繁地維護 CDN、WAF、SLB、Ingress 等多種網(wǎng)絡設施配置。如果需完全保證 X-Forwarded-For 不可偽造,對于要升級 Gin 框架的 Go 服務來說,只有如下兩種方案:
?繼續(xù)嘗試通過 X-Forwarded-For 獲取客戶端真實 IP。?嘗試通過其他 Header 獲取客戶端真實 IP。
4.1. 繼續(xù)嘗試通過 X-Forwarded-For 獲取客戶端真實 IP
業(yè)務中需配置基礎設施所有前置代理到 TrustedProxies 中,包含 CDN 地址池、WAF 地址池、Kunernetest Nginx Ingress 地址池,這種方案基本無法落地:
?配置太過復雜,一旦獲取 IP 不準,很難排查。?導致業(yè)務配置和基礎設施耦合,基礎設施如果對 CDN、WAF、Ingress 做變動,業(yè)務代碼必須同步變更。?部分可信代理 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 的服務不需做任何修改。對于使用的國內(nèi)云廠商的服務,則需要顯式的配置 engine. AppEngine = true,然后繼續(xù)通過 ctx.ClientIP() 方法即可。?如果使用其他自定義 Header,如 X-Client-Real-IP 來獲取客戶端真實 IP,建議可以考慮自行封裝 ClientIP(*gin.Context) string 函數(shù),從 X-Client-Real-IP 中獲取客戶端 IP。
資料鏈接:
https://datatracker.ietf.org/doc/html/rfc7239
https://github.com/gin-gonic/gin/issues/2697
實戰(zhàn)群
