獲取使用者端真實 IP 地址的最佳實踐

2023-07-23 12:00:22

一、背景

1. 業務上雲帶來效能收益

公司從去年全面推動業務上雲,而以往 IDC 架構部署上,接入層採用典型的 4 層 LVS 多機房容災架構,在業務高峰時期,擴容困難(受限於物理機資源和 LVS 內網網段的網路規劃),且抵擋不住 HTTPS 解除安裝引發的高 CPU 佔用。

而經過壓力測試發現,使用騰訊雲 7 層 CLB 負載均衡進行 HTTPS 解除安裝,效能得到極大提升。測試資料也表明,IDC 舊架構中,啟用 HTTPS 會帶來 90% 以上的效能損耗。

2. 架構調整引發多次故障

引入騰訊雲 7 層 CLB 負載均衡產品,帶了了巨大的效能提升,卻也給業務帶來了痛苦,主要核心問題是獲取使用者端的真實 IP 上。

當前現狀是業務語言異構(PHP + Go),多數業務已經歷服務化改造,但缺乏服務發現機制,服務與服務之間的呼叫依賴域名和 DNS 解析,大部分都是 HTTP 服務。

在架構調整後,由於未能 100% 覆蓋測試,導致漏測的服務經常拿到錯誤的使用者端 IP 地址,造成的後果是損失大量的使用者。這些使用者會因為簡訊驗證碼傳送限制、IP 登入頻次過高而無法登入、充值,給公司帶來巨大損失。

3. 未來的路應該怎麼走?

更進一步講,當前業務如何抵擋外界的 DDoS 攻擊、請求機器人、SQL 注入等等,最簡單的是接入高防 IP、WAF 應用防火牆,而請求經過多輪轉發,同樣也有獲取使用者端真實 IP 的問題。

再者,業務也在逐步容器化,享受 Kubernetes 彈性擴容的便利,怎麼平滑遷移也是非常值得深思的。

假設有一天某個同學,不小心設定有誤——應用層拿到的,很有可能是高防 IP 或者 WAF 的 IP,業務絕對無法忍受。

顯然,確定一個業務無感知的方案併成功落地迫在眉睫。

然而翻遍整個網際網路,幾乎沒有文章能把這些看起來很簡單的事情捋清楚、講明白,更不用說最佳實踐。

大多數人都是抄抄設定,潦潦草草上線,方案並沒有普適性。

這篇文章也是我在這段時間的研究中總結出來的寶貴經驗,希望對讀者能有些許幫助。文章篇幅較長,難免有錯誤之處,還請各位看官斧正,感激不盡:)

二、名詞釋義

1. REMOTE-ADDR

  • Nginx + PHP 模式下,REMOTE-ADDR 為遠端的 IP 地址,可通過 $_SERVER['REMOTE-ADDR'] 獲取;
  • 它代表與上一層建立 TCP 連線的 IP 地址;
  • 網站無代理時(使用者端->伺服器端),WEB伺服器(Nginx,Apache等)會設定該值為使用者端 IP;
  • 網站存在代理時(使用者端->代理->伺服器端),該值為代理的 IP。
proxy_set_header REMOTE-ADDR $remote_addr;

2. X-Forwarded-For

X-Forwarded-For 是一個 HTTP 擴充套件頭部。HTTP/1.1(RFC 2616)協定並沒有對它的定義,它最開始是由 Squid 這個快取代理軟體引入,用來表示 HTTP 請求端真實 IP。如今它已經成為事實上的標準,被各大 HTTP 代理、負載均衡等轉發服務廣泛使用,並被寫入 RFC 7239(Forwarded HTTP Extension)標準之中。

  • 格式為英文逗號 + 空格隔開,例如:X-Forwarded-For: IP0(client), IP1(proxy), IP2(proxy);

  • 中間經過的代理,會逐層追加至末尾;

  • IP0 離伺服器端最遠,然後是每一級代理裝置的 IP,IP2 直連伺服器端。

  • 如果使用者端偽造 IP 地址,格式為:X-Forwarded-For: 偽造的 IP 地址 1, [偽造的 IP 地址 2...], IP0(client), IP1(proxy), IP2(proxy)。

3. X-Real-IP

注:CLB <=> SLB,為騰訊雲和阿里雲不同產品的稱呼,均為負載均衡。

典型的呼叫鏈路:

client --> ① [CLB-7]gateway --域名--> ② [CLB-7]server(③ nginx + ④ go/php)
  • X-Real-IP 為建立 TCP 連線的上一跳的 IP 地址;
  • 對於 ④ 而言,X-Real-IP 為 ① 閘道器的 NAT 公網出口 IP 地址,或 gateway 的內網 IP 地址,該結論通過生產環境 tcpdump 抓包驗證得到;
  • 公網呼叫下,① 閘道器 呼叫 ② 7 層 CLB,再到應用層 ③④,此時 ④ 拿到的 X-Real-IP 為 ① 的 NAT 公網出口地址(7 層 CLB 會重寫 X-Real-IP 頭部,並追加 X-Forwarded-For 頭部);
  • 內網環境中,原理相似,只不過拿到的是 gateway 的內網 IP 地址;
  • 中間可能被 ③ nginx 重寫,此時等同於 REMOTE-ADDR。

比如以下最常見的 nginx 設定:

proxy_set_header  REMOTE-ADDR     $remote_addr;
proxy_set_header  X-Real-IP       $remote_addr;
proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;

REMOTE-ADDR 和 X-Real-IP 都是 nginx 的 $remote_addr 變數,再傳遞給下游。

三、面臨困境

1. 運維側

  • 業務線設定五花八門,沒有統一。具體表現在 nginx.conf 和 vhost 設定在不同的業務線有很大區別;
  • vhost 成千上萬,nginx 內部存在多重轉發,外部也有閘道器轉發過來的流量,且閘道器不止一套,捋不清鏈路容易導致線上故障;
  • 缺乏完善的 QA 驗證流程,變更沒辦法 100% 覆蓋測試,最終結果就是儘可能少變更,但這不是長久之計;
  • 存在開發自行維護信任 IP 的情況,所以運維不敢隨便變更,因為變更前需要通知開發整改,開發有自己的時間排期,處理起來效率極其低下;
  • 為了儘可能少修改原先的設定,部分機器組接入了騰訊雲的 TOA 模組,用來獲取使用者端真實 IP 地址,而阿里雲沒有相似的產品,如果沒有統一的方案,沒辦法上線阿里雲,實現不了雙雲雙活的目標等等。

2. 開發側

各個業務線使用的技術棧不統一,存在多種獲取使用者端 IP 的方案,需要找到一種儘可能少修改程式碼,或者一點都不需要修改程式碼的方案。

PHP 以 Laravel 框架為例(底層是 Symfony 框架),發現內部取了 $_SERVER['REMOTE_ADDR'] 變數:

public function getClientIp()
{
    $ipAddresses = $this->getClientIps();
    return $ipAddresses[0]; // 1. 取第一個 IP 地址。
}
public function getClientIps()
{
    $ip = $this->server->get('REMOTE_ADDR');
    if (!$this->isFromTrustedProxy()) {
        // 2. 程式在這裡返回了 REMOTE_ADDR 頭部的值。
        return [$ip];
    }
    // 3. 永遠到不了這個分支。
    return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
}
public function isFromTrustedProxy()
{
    // 4. 因為生產環境中,$trustedProxies 沒有設定。
    return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies);
}

公司內部有些業務自己實現函數,依賴的是 X-Forwarded-For 頭部。

Go 以 Gin 框架為例,準確的說是 [email protected]. 版本,它先取 X-Forwarded-For 的第一個 IP,取不到就取 X-Real-IP 頭部:*

func (c *Context) ClientIP() string {
	// 1. ForwardedByClientIP 預設為 true
	if c.engine.ForwardedByClientIP {
		// 2. 優先獲取 X-Forwarded-For 頭部
		clientIP := c.requestHeader("X-Forwarded-For")
		// 3. 取 X-Forwarded-For 的第一個 IP 地址
		clientIP = strings.TrimSpace(strings.Split(clientIP, ",")[0])
		// 4. 取不到就取 X-Real-Ip 欄位
		if clientIP == "" {
			clientIP = strings.TrimSpace(c.requestHeader("X-Real-Ip"))
		}
		// 5. 拿到了就直接返回(正常的邏輯)
		if clientIP != "" {
			return clientIP
		}
	}
	// 6. 忽略,該值為 false,除非 build tags 包含 appengine 為 true
	if c.engine.AppEngine {
		if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
			return addr
		}
	}
	// 7. 以上都取不到的話,取 RemoteAddr 欄位,走到這個邏輯,程式肯定不正常。
	// 參考 Go 標準庫,該值為 TCP 建立連線的遠端 IP 地址
	// go1.17.1/src/net/http/server.go:1003
	// req.RemoteAddr = *conn.remoteAddr
	if ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)); err == nil {
		return ip
	}
	return ""
}

經過調研發現,業務取的是 X-Real-IP 欄位,具體原因就不展開了。

至於 [email protected].* 版本,由於 [email protected].* 的實現存在偽造使用者端 IP 的問題,被爆 CVE-2020-28483 漏洞,官方為了修復這個問題,換了一種實現修復該漏洞:

func (c *Context) ClientIP() string {
	// 1. 自定義 Header 的情況,可以忽略
	if c.engine.TrustedPlatform != "" {
		if addr := c.requestHeader(c.engine.TrustedPlatform); addr != "" {
			return addr
		}
	}
	if c.engine.AppEngine {
		if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
			return addr
		}
	}
	// 2. 獲取 IP 地址,並返回是否可以信任
	remoteIP, trusted := c.RemoteIP()
	if remoteIP == nil {
		return ""
	}
	// 3. 如果信任,檢查 IP 地址的合法性,合法就返回
	// 預設值:ForwardedByClientIP=true,RemoteIPHeaders=[X-Forwarded-For(優先), X-Real-IP]
	if trusted && c.engine.ForwardedByClientIP && c.engine.RemoteIPHeaders != nil {
		for _, headerName := range c.engine.RemoteIPHeaders {
			// c.requestHeader 在頭部有效的情況下,也是返回第一個 IP 地址。
			ip, valid := validateHeader(c.requestHeader(headerName))
			if valid {
				return ip
			}
		}
	}
	// 4. 不能信任,那就用 TCP 連線遠端 IP 兜底。
	return remoteIP.String()
}
func (c *Context) RemoteIP() (net.IP, bool) {
	ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
	if err != nil {
		return nil, false
	}
	remoteIP := net.ParseIP(ip)
	if remoteIP == nil {
		return nil, false
	}
	// remoteIP = TCP 連線遠端 IP 地址
	// 由於業務沒有設定 engine.TrustedProxies,所以是不可信任的。
	return remoteIP, c.engine.isTrustedProxy(remoteIP)
}
func (e *Engine) isTrustedProxy(ip net.IP) bool {
	if e.trustedCIDRs != nil {
		for _, cidr := range e.trustedCIDRs {
			if cidr.Contains(ip) {
				return true
			}
		}
	}
	// 業務將會走到這裡!
	return false
}
func (e *Engine) validateHeader(header string) (clientIP string, valid bool) {
	if header == "" {
		return "", false
	}
	items := strings.Split(header, ",")
	for i := len(items) - 1; i >= 0; i-- {
		ipStr := strings.TrimSpace(items[i])
		ip := net.ParseIP(ipStr)
		if ip == nil {
			return "", false
		}
		// X-Forwarded-For is appended by proxy
		// Check IPs in reverse order and stop when find untrusted proxy
		if (i == 0) || (!e.isTrustedProxy(ip)) {
			return ipStr, true
		}
	}
	return
}

官方的手法也是簡單粗暴,以前是將錯就錯,這次一下子修復好了,搞得很多人翻車了(https://github.com/gin-gonic/gin/issues/2697)。

原因是新的實現沒有相容 1.6 版本,導致升級框架後獲取不到使用者端的真實 IP,1.7.7 才解決該問題。

四、三大原則

分析完整個事情的來龍去脈,想必讀者們對現狀有一定的瞭解。

我把這套方案,抽象為三大原則,只要理解它,獲取使用者端真實 IP 的問題,就跟喝水一樣簡單!

1. 代理必須向下傳遞使用者端 IP 地址

原因:從入口流量開始,經過 N 層代理,如果代理中間不傳遞使用者端的 IP 地址,底層業務必然獲取不到使用者端的真實 IP 地址

2. 統一使用 nginx 的 realip 模組獲取使用者端 IP 地址

# nginx.conf
# ...
set_real_ip_from 騰訊雲/阿里雲 NAT 出口網段;
set_real_ip_from 騰訊雲/阿里雲高防 IP 網段;
set_real_ip_from 騰訊雲/阿里雲 WAF 網段;
set_real_ip_from CDN 網段;
set_real_ip_from 內網地址網段; # 按需設定,對於閘道器進來的請求通過內網到業務機器,需要設定上這個網段。
set_real_ip_from 127.0.0.1;  # 按需設定,主要作用在 nginx 的內部轉發。
real_ip_header X-Forwarded-For;
real_ip_recursive on;        # 必須開啟該選項,原因見下面分析。
access_by_lua '
    ngx.req.set_header("X-REAL-IP",       ngx.var.remote_addr)
    ngx.req.set_header("X-FORWARDED-FOR", ngx.var.remote_addr)
';

# vhost/*.conf
location ^~ /foo {
    access_log         logs/api_foo.access.log main;
    proxy_pass         http://api_foo;
    proxy_redirect     off;
    proxy_http_version 1.1;
    proxy_set_header   Host            $http_host;
    proxy_set_header   X-NginX-Proxy   true;
    proxy_set_header   Connection      "";
}

此時,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均統一為 realip 模組重寫後的 $remote_addr 變數,業務就可以取到真實的使用者端 IP 地址,無需考慮 PHP、Go 等不同語言、同種語言不同框架下的差異。

那問題來了,使用者端 IP 是否會被偽造?答案是不會的。

按照 X-Forwarded-For 的定義,該頭部每經過一層就追加一個 IP 地址:

X-Forwarded-For: 使用者端偽造 IP 地址, IP0(client), IP1(proxy), IP2(proxy)

那麼,我們只需啟用 realip 模組的 real_ip_recursive 遞迴模式,將從右往左逐步剔除 IP2,IP1 等信任代理,最後會獲取到真實的使用者端 IP 地址。

問題二:網上有一種邊緣節點的方案,為什麼不採用?

邊緣節點,指的就是接入層,直接連線使用者端的那一層。經過邊緣節點轉發到下游的,統稱為非邊緣節點。

按照這個思路,如果邊緣節點拿到了使用者端 IP,重置 X-FORWARDED-FOR 頭部為使用者端 IP 地址,並轉發到下游,業務只獲取第一個 IP 地址,理論上也不會被偽造,業務也簡單,為什麼不採用?

因為邊緣節點方案最大的缺點在於失去了靈活性,譬如你想接入高防 IP 或者 WAF 防火牆,此時它已不再是邊緣節點,而是接收高防伺服器或 WAF 防火牆清洗的流量,將會拿到錯誤的 IP 地址。

3. 運維維護信任 IP 列表,開發程式碼不做處理

由 2 可知,三個頭部均為統一的值,對開發可以保證最大的相容性。原因是不同的語言,同個語言的不同開發框架,同個框架的不同版本,獲取使用者端 IP 的方式也就這幾種。

對開發而言,確實沒必要關心自己的程式碼需要引入 NAT 閘道器 IP 設定、高防 IP 設定等,並且每個工程可能都要修改,這是不現實的。

本質上,這也是運維的工作。舉個例子,如果真的遇到 DDoS 攻擊,切換高防 IP 抵禦 DDoS 攻擊的操作人是運維,開發這個時候去將所有工程設定上高防 IP 地址是一件極其痛苦的事情。一旦加漏、加錯將直接引發故障。

五、最佳實踐

(1) 虛擬機器器部署

  1. SRE 維護信任的 IP 池,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均統一為 realip 模組重寫後的 $remote_addr 變數,開發不感知;
  2. 開發無需修改程式碼,因為上述三個變數讀取出來的值是一致的,無任何風險。

(2) 容器化部署

a. PHP 無需改動,可以平滑切換上容器。因為 PHP 容器上層依然有 nginx.conf,平移該設定即可;

b. GO 容器化,有 2 種方案:

注:最終採用方案 2,去除了 Pod 內部的 nginx 轉發,Pod 的上層使用了 nginx-ingress,做到了業務無感知容器上雲。

  1. 如果保留虛擬機器器架構,即 Go 服務上層有 nginx,也是平移就可以了,跟 PHP 一樣;

  2. 如果 Go 服務上游去除 nginx 轉發:

    流量入口使用 7 層騰訊雲 CLB / 阿里雲 SLB 進行 HTTPS 解除安裝後轉發到容器叢集的 nginx-ingress,業務程式碼無感知。實現原理和虛擬機器器方案相似,均為設定 realip 模組和統一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 頭部,詳情可以參考以下資料:

還有個容易忽略的點——ingress 選型。

如果使用 Pod 直連,也就是不使用 nginx-ingress:

PHP / Go 上層都需要有一層 nginx 並設定好 nginx.conf,設定 realip 模組和統一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 頭部。

此時 PHP / Go 架構統一,但對 Go 容器來說多了一層 nginx,會造成資源浪費(每個 Pod 都需要部署一個 nginx,再轉發到 Go)。

具體用哪個 ingress,就要看怎麼取捨了。

nginx 存在的意義在於阻止業務直接感知到信任代理 IP 列表的存在,如果對於你的業務而言,各個業務線去維護這個設定列表成本極低,那 nginx 確實是沒有存在的必要性。


總之,我個人認為:

  1. 業務完全不需要關心如何獲取使用者端的真實 IP,這是最好的選擇;
  2. 千萬不要封裝各種函數去獲取使用者端真實 IP,這種問題最好交給上層 SRE 基礎架構的同學負責,不然真的非常容易出問題;
  3. 理解好三大原則,獲取使用者端真實 IP 的問題,就跟喝水一樣簡單!

OK,文章終於寫完了,花費了好多天的時間整理,憋出來了。感謝你讀到這裡,是時候吃晚飯了:)


文章來源於本人部落格,釋出於 2021-12-19,原文連結:https://imlht.com/archives/248/