DoS(Denial of Service,阻斷服務)攻擊,又稱為洪水攻擊,是常見的網路攻擊手段。利用一台電腦發出大量的連線請求(Request),使目標電腦的網路或系統資源耗盡,使之當機或是無法再回覆正常的請求。Nginx是免費開源、穩定高效的Web伺服器,非常廣泛地被使用。Nginx的功能強大,除了可以作反向代理和負載平衡外,也可以用來防禦小規模的DoS、DDoS(Distributed Denial of Service,分散式阻斷服務)攻擊。



Nginx的ngx_http_limit_req_module模組

「ngx_http_limit_req_module」是Nginx預設啟用的模組,可以用來限制同一個IP在每秒內或是每分鐘內的連線次數。使用「ngx_http_limit_req_module」模組時,必須先在Nginx設定檔中的「http」區塊內撰寫「limit_req_zone」命令,來建立出用來儲存每個IP連線次數的共享記憶體空間,成為「Zone」。

例如:

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

以上命令,可以建立出一個名為「mylimit」的「Zone」,擁有10MiB的大小的共享記憶體空間,為key-value結構。這個key-value結構所用到的「key」,可以是任意未滿64個位元組(32位元環境)或是128個位元組的資料(64位元環境),在此我們將其指定為「$binary_remote_addr」,也就是請求端的IP位址,如果請求端使用IPv4,則位址會佔用4個位元組;如果請求端使用IPv6,則位址會佔用16個位元組。而每筆key-value資料,在32位元的作業系統中,會使用64個位元組;而在64位元的作業系統中,則會使用128個位元組。也就是說,假設連入64位元伺服器的IP位址都是使用IPv4的話,10MiB的Zone可以儲存10 * 1024 * 1024 / 128 / = 81920筆key-value資料。當這個Zone已經存滿時,如果來了新的來自陌生IP位址的請求,就會把最久不使用的key-value資料從Zone中清除,將新進來的請求所使用的IP位址遞補上去。至於命令中「rate」設定值的部份,「10r/s」(10 requests / second)表示來自同一個IP位址的請求,Nginx每秒最多只會執行10個,換句話說100毫秒才會執行一個。至於如果在100毫秒內有兩次以上來自同一IP位址的請求,Nginx的處理方式則要使用其它命令來調整,這邊的「limit_req_zone」命令只是先規定好一個大方向。

如果想要將「rate」設定值設小於「1r/s」,也就是每次請求的間隔要在一秒以上的話,可以改用「r/m」(requests / minute)單位,來限制每分鐘的請求數量。雖然「rate」設定值用的單位是「r/s」和「r/m」但最終都會被換算成「每隔一段時間執行一個請求」,例如「10r/s」實際上是「100毫秒執行一個請求」,而「600r/m」實際上也是「100毫秒執行一個請求」。也因此就不要肖想會有「r/h」(requests / hour)、「r/d」(requests / day)、「r/w」(requests / week)等單位了。

另外,Nginx並沒有限制「limit_req_zone」命令的使用次數,所以也可以建立出許多不同的「Zone」來使用。

總而言之,有了「Zone」之後,我們就可以在使用Nginx設定檔中的「http」區塊或是其子區塊(如server或location)內,使用「limit_req」命令,來限制來自同一IP的連線之請求間隔時間。例如:

limit_req zone=mylimit;

以上命令,可以使用「mylimit」這個「Zone」。如果「mylimit」的「rate」設定值為「10r/s」,則來自同一IP的連線之請求間隔時間為100毫秒,如果在100毫秒內就有請求進來,Nginx預設會回傳HTTP的503狀態(代表服務暫時無法使用,Service Temporarily Unavailable)。若想要更改請求過快時,Nginx所回傳的HTTP狀態,可以使用「limit_req_status」命令。例如:

limit_req_status 444;

以上命令,會使得Nginx在來自同一IP的連線之請求間隔時間過快時,回傳HTTP的444狀態。

然而,如果我們的Web伺服器是網頁服務的話,直接使用「limit_req」命令指定一個「Zone」並不是一個適當的作法,因為瀏覽器開啟一個網頁時,會同時對該伺服器發出多個請求,至於請求數量要看網頁是怎麼設計的。因為請求是「同時」發出,會導致只有其中一個請求會被正常處理,而其它緊接在後的請求全都會被當作「間隔過快的」請求,而造成網頁顯示出問題。因此,我們在使用「limit_req」命令時,還需要指定「burst」設定值,來讓「limit_req」命令有能力處理首批比較大量的請求。例如:

limit_req zone=mylimit burst=30;

以上命令,將「burst」設為30,表示Nginx最多可以暫存30個來自同一個IP位址的請求。如果「mylimit」的「rate」設定值為「10r/s」,則會以「100毫秒」執行一個暫存中的請求的速度,來將所有暫存中的請求處理完畢。若新的請求進來時,「burst」的暫存空間已經滿了,這個新的請求就會立刻被當作是「間隔過快的」請求。因此,這個「burst」的設定值,最好要大於或等於網站的任意網頁所可能會產生的最大請求量,才不會導致網頁一開就出問題。

但是,只加上「burst」設定值的「limit_req」命令還是會有一個很大的問題,那就是排在後面的請求必須要等待前面所有的請求都被執行後才會被執行。如果「mylimit」的「rate」設定值為「10r/s」,則第21個請求要等前面20個請求都被執行後,也就是要經過至少2秒鐘之後才會被執行,這樣的話很可能會導致網頁要等待好幾秒才能完全載入好。因此,我們在使用「limit_req」命令時,除了要指定「burst」設定值外,通常還要搭配「nodelay」來使用。例如:

limit_req zone=mylimit burst=30 nodelay;

以上命令,由於多加了「nodelay」,暫存於「burst」的請求不需要等待前面的請求即可被執行,但是被執行的請求並不會立刻移出「burst」的暫存中。如果「mylimit」的「rate」設定值為「10r/s」,則會以「100毫秒」移除一個暫存中已執行的請求的速度,逐一將「burst」的暫存空間清空,使它可以繼續接收新的請求。若「burst」在當下已沒有空間,新的請求還是會立刻被當作是「間隔過快的」請求。這樣的機制可以讓Nginx有能力處理首批比較大量的請求,同時也能防禦後續短時間內又有大量請求的出現。

當然,對於需要被搜尋引擎搜尋到的網頁來說,我們要儘可能地張開雙手迎接搜尋引擎「爬蟲」(Crawler)的進入。若對搜尋引擎的爬蟲也限制一段時間內的請求數量,對網站的SEO絕對會有不利的影響。因此,這套限制IP的機制,必須還要有白名單功能才可以拿來實際運用在公開的網站上。我們可以改寫一下原本「limit_req_zone」命令的寫法,搭配「geo」命令和「map」命令來使用。

Nginx的「geo」命令,可以判斷某IP(不指定的話就是請求端的IP)是否在某CIDR或是IP範圍內。寫法如下:

geo $limit {
    default 1;
    10.0.0.0/8 0;
    192.168.18.5-192.168.18.5 0;
}

以上的「geo」命令,可以判斷請求端的IP,如果就是「192.168.18.5」或是在「10.0.0.0/8」這個CIDR下的話,「$limit」就是「0」;否則「$limit」就是「1」。

至於Nginx的「map」命令,可以判斷某值究竟是什麼值,來輸出另一個值到其它的變數。寫法如下:

map $limit $limit_key {
    0 "";
    1 $binary_remote_addr;
}

以上的「map」命令,可以判斷「$limit」的值。當「$limit」為「0」時,「$limit_key」就會被設為「""」;當「$limit」為「1」時,「$limit_key」就會被設為請求端的IP位址($binary_remote_addr)。

接著,我們可以將「limit_req_zone」命令改寫成:

limit_req_zone $limit_key zone=mylimit:10m rate=10r/s;

如此一來,寫在「geo」命令內的CIDR或是IP範圍就是限制同一IP請求數量機制的白名單了!

至於搜尋引擎的爬蟲所使用的IP究竟有哪些,就要動手去查了。筆者推薦以下這個網站提供的免費資料,因為它有提供CIDR格式,套用起來比較容易~

https://www.ip2location.com/free/robot-whitelist