จำกัดการยิง login และ API ด้วย nginx limit_req: burst, nodelay, 429, fail2ban
ตั้ง rate limit หน้า login และ API ด้วย nginx limit_req ให้ถูกความหมาย burst กับ nodelay คำนวณว่าสคริปต์ยิง 100 ครั้งต่อนาทีผ่านได้กี่ครั้ง และทำไมต้องจำกัดต่อบัญชีด้วย
- rate=5r/m คือคืนโควตา 1 ครั้งทุก 12 วินาที burst=5 nodelay ให้ผ่านทันทีได้ 6 ครั้ง ที่เหลือได้ 429
- สคริปต์ยิง 100 ครั้งต่อนาทีจาก IP เดียว ผ่านได้ 10 ครั้งในนาทีแรก แล้ว 5 ครั้งต่อนาทีหลังจากนั้น
- limit ต่อ IP ไม่พอสำหรับหน้า login ต้องนับครั้งที่ผิดต่อบัญชีในแอปด้วย เพราะผู้โจมตีเปลี่ยน IP ได้
- ตั้ง limit_req_status 429 พร้อม Retry-After และให้ fail2ban แบน IP ที่โดนจำกัดซ้ำเป็นชั้นที่สอง
หน้า login และ API ที่เปิดสู่อินเทอร์เน็ตจะโดนสคริปต์ยิงซ้ำตั้งแต่วันแรก ทั้งเดารหัสผ่าน ลองรหัสที่หลุดจากเว็บอื่น และดึงข้อมูลรัว ๆ rate limit คือการบอกว่า “ต่อหนึ่งคนต่อช่วงเวลา รับได้เท่านี้” บทความนี้ตั้งสองชั้น ชั้นแรกที่ nginx ต่อ IP ชั้นที่สองในแอปต่อบัญชี แล้วเสริม fail2ban พร้อมคำนวณให้ดูว่าตัวเลขที่ตั้งได้ผลจริงแค่ไหน
คอนฟิกเขียนสำหรับ Ubuntu 24.04, nginx 1.24.0 และ fail2ban 1.0.2 (เวอร์ชันในแพ็กเกจของ Ubuntu 24.04) ความหมายของ burst และ nodelay ตรวจกับเอกสาร nginx และสูตรใน ngx_http_limit_req_module.c ตัวเลขในตัวอย่างคำนวณจากสูตรนั้น
limit_req ทำงานยังไง
nginx ใช้แนวคิด leaky bucket ต่อหนึ่ง key (ปกติคือ IP) เก็บตัวนับ “คำขอเกิน” (excess) ไว้
- ทุกคำขอที่เข้ามา excess +1
- excess ลดลงตามเวลา ด้วยอัตรา
ratenginx นับละเอียดระดับมิลลิวินาทีrate=5r/mจึงหมายถึงคืนโควตา 1 ครั้งทุก 60 ÷ 5 = 12 วินาที ไม่ใช่ “นาทีละ 5 ครั้งแล้วรีเซ็ต” - ถ้า excess หลังบวกแล้วเกิน
burstคำขอนั้นถูกปฏิเสธ และไม่นับเข้า bucket
| ตัวเลือก | ความหมาย |
|---|---|
rate=5r/m | อัตราเฉลี่ยที่ยอม ต่ำกว่า 1 ครั้งต่อวินาทีให้เขียนเป็น r/m |
burst=5 | ยอมให้เกินอัตราได้สะสม 5 ครั้ง ค่าเริ่มต้นคือ 0 |
ไม่ใส่ nodelay | คำขอใน burst ถูก หน่วง ให้ออกตามจังหวะ rate |
nodelay | คำขอใน burst ผ่านทันที แต่ช่องใน bucket ยังคืนตามจังหวะ rate |
delay=N (1.15.7+) | ผ่านทันที N ครั้งแรก เกินจากนั้นถึงค่อยหน่วง |
limit_req_status | status ที่ส่งตอนปฏิเสธ ค่าเริ่มต้น 503 |
ค่าเริ่มต้น 503 แปลว่า “เซิร์ฟเวอร์ไม่พร้อม” ซึ่งผิดความหมาย ควรตั้งเป็น 429 Too Many Requests ตาม RFC 6585 ที่บอกว่าเป็นคำขอเยอะเกินจากฝั่ง client และ cache ห้ามเก็บ
ตัวอย่างคำนวณ: สคริปต์ยิง 100 ครั้งต่อนาที
ตั้ง rate=5r/m burst=5 nodelay แล้วมีสคริปต์จาก IP เดียวยิงหน้า login 100 ครั้งต่อนาที คือทุก 0.6 วินาที
- โควตาคืนทุก 12 วินาที ใน 0.6 วินาทีจึงคืนได้ 0.6 ÷ 12 = 0.05 ครั้ง
- คำขอแรก (t=0) ผ่าน excess = 0
- คำขอที่ 2 ถึง 6 (t=0.6 ถึง 3.0) แต่ละครั้ง +1 −0.05 excess ขึ้นเป็น 0.95, 1.9, 2.85, 3.8, 4.75 ยังไม่เกิน 5 ผ่านทั้งหมด รวม 6 ครั้ง (1 + burst)
- คำขอที่ 7 (t=3.6) excess จะเป็น 5.7 เกิน 5 ได้ 429
- จะผ่านอีกครั้งต้องให้ excess ลดจาก 4.75 เหลือไม่เกิน 4 คือลด 0.75 ครั้ง = 0.75 × 12 = 9 วินาที หลัง t=3.0 คำขอถัดไปที่มาหลังจุดนั้นคือ t=12.6 ผ่าน
- จากนั้นผ่าน 1 ครั้งทุก ~12 วินาที (t=24.6, 36.6, 48.6, …)
| ช่วง | ผ่าน | ได้ 429 |
|---|---|---|
| นาทีแรก | 10 | 90 |
| ทุกนาทีถัดไป | 5 | 95 |
| ทั้งวัน (ต่อ IP) | ≈ 5 × 1,440 = 7,200 | ที่เหลือทั้งหมด |
ตัวเลขนี้ตรงกับการจำลองสูตรใน source ของ nginx ทีละมิลลิวินาที ข้อสรุปคือ ต่อ IP เดียว สคริปต์ถูกตัดเหลือ 5% แต่ถ้าผู้โจมตีมี 1,000 IP ก็ยังลองได้ 7,200 × 1,000 = 7.2 ล้านครั้งต่อวัน limit ที่ nginx จึงเป็นแค่ชั้นแรก
คอนฟิก nginx
ส่วนประกาศ zone อยู่ใน context http บน Ubuntu วางใน conf.d ได้เลยเพราะ nginx.conf include ไว้ใน http อยู่แล้ว
# /etc/nginx/conf.d/ratelimit.conf
# 10m เก็บได้ราว 80,000 IP บนเครื่อง 64-bit (1 MB ≈ 8,000 state ขนาด 128 ไบต์)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_status 429;
# ใน server { } ของเว็บ
error_page 429 = @ratelimited;
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location @ratelimited {
add_header Retry-After 60 always;
default_type application/json;
return 429 '{"error":"too_many_requests"}';
}
nginx ไม่ใส่ Retry-After ให้เอง จึงส่งไปที่ named location ที่ตั้ง header เอง ค่า 60 วินาทีเผื่อไว้มากกว่า 12 วินาทีที่โควตาคืนจริง ให้ client ที่เขียนดีหยุดรอ ข้อควรระวัง: location ที่มี add_header ของตัวเองจะไม่สืบทอด header จากระดับ server ถ้าใช้ security header อยู่ต้อง include ซ้ำใน location นี้ด้วย (รายละเอียดใน security headers บน nginx)
ทดสอบก่อน reload
sudo nginx -t && sudo systemctl reload nginx
ถ้ากลัวตั้งแรงไปจนผู้ใช้จริงโดน เปิด limit_req_dry_run on; (มีตั้งแต่ 1.17.1) ไว้ก่อนสักสองสามวัน nginx จะนับและเขียน log แต่ไม่บล็อก แล้วค่อยดูว่าใครโดนบ้าง
ชั้นที่สอง: จำกัดต่อบัญชีในแอป
nginx เห็นแค่ IP ไม่รู้ว่าคำขอนั้นพยายามเข้าบัญชีไหน OWASP แนะนำให้ ผูกตัวนับครั้งที่ล็อกอินผิดไว้กับบัญชี ไม่ใช่ IP ผู้โจมตีจะเปลี่ยน IP กี่ตัวก็นับรวมกัน ตัวอย่างด้วย Python + Redis นับครั้งที่ผิดในหน้าต่าง 15 นาที
import redis
r = redis.Redis()
WINDOW = 15 * 60 # วินาที
MAX_FAILS = 10
def _key(username: str) -> str:
return f"loginfail:{username.strip().lower()}"
def login_blocked(username: str) -> int:
"""คืนจำนวนวินาทีที่ต้องรอ 0 = ลองได้"""
k = _key(username)
if int(r.get(k) or 0) >= MAX_FAILS:
return max(r.ttl(k), 1)
return 0
def record_failure(username: str) -> None:
k = _key(username)
p = r.pipeline()
p.set(k, 0, ex=WINDOW, nx=True) # เริ่มหน้าต่างใหม่ถ้ายังไม่มี
p.incr(k)
p.execute()
def record_success(username: str) -> None:
r.delete(_key(username))
ใน handler ของ login ถ้า login_blocked() ได้ค่ามากกว่า 0 ให้ตอบ 429 พร้อม Retry-After เท่ากับค่านั้น ข้อความ error ต้องเหมือนกัน ไม่ว่าจะเป็นรหัสผิด ไม่มีบัญชีนี้ หรือบัญชีถูกพักไว้ ไม่อย่างนั้นผู้โจมตีใช้ข้อความต่างกันไล่หาว่าบัญชีไหนมีจริง
เทียบตัวเลขกับตัวอย่างก่อนหน้า
| วัดต่อ | ลองได้สูงสุดต่อวัน |
|---|---|
| IP เดียว ผ่าน nginx 5r/m | ≈ 7,200 |
| 1,000 IP ผ่าน nginx | ≈ 7,200,000 |
| บัญชีเดียว 10 ครั้ง / 15 นาที | 10 × (1,440 ÷ 15) = 10 × 96 = 960 ไม่ว่าจะมีกี่ IP |
NIST SP 800-63B กำหนดเพดานไว้ว่า ผิดติดกันกับบัญชีเดียวต้องไม่เกิน 100 ครั้งก่อนปิดวิธียืนยันตัวตนนั้น จะเพิ่มกติกานี้ซ้อนอีกชั้นก็ได้ ถ้าเก็บตัวนับไว้ใน Redis อยู่แล้ว (ใช้ Redis ให้ถูกที่อ่านต่อได้ที่นี่)
nginx กันปริมาณ แอปกันการเดารหัสของบัญชีเดียว ต้องมีทั้งสองชั้น ชั้นใดชั้นหนึ่งอย่างเดียวมีช่องให้ลอด
ชั้นที่สาม: fail2ban แบน IP ที่โดนจำกัดซ้ำ
nginx ปฏิเสธแล้วแต่สคริปต์ยังยิงต่อได้ ใช้ CPU และ log เรา fail2ban อ่าน error log ของ nginx หาบรรทัด limiting requests, excess: ... by zone "..." (ระดับ error ตามค่าเริ่มต้นของ limit_req_log_level) แล้วแบนที่ firewall fail2ban มี filter nginx-limit-req มาให้แล้ว แค่เปิด jail
# /etc/fail2ban/jail.d/nginx-limit-req.local
[nginx-limit-req]
enabled = true
# Ubuntu 24.04 ตั้งค่าเริ่มต้นเป็น systemd แต่ nginx เขียน log ลงไฟล์
backend = auto
port = http,https
logpath = /var/log/nginx/error.log
findtime = 10m
maxretry = 10
bantime = 1h
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-limit-req
บรรทัด backend = auto สำคัญ ไฟล์ jail.d/defaults-debian.conf ของ Ubuntu 24.04 ตั้ง backend = systemd ไว้ที่ [DEFAULT] ซึ่งอ่านจาก journal แทนไฟล์ แต่ error log ของ nginx อยู่ในไฟล์ ถ้าไม่ตั้ง jail จะเปิดแต่ไม่เคยแบนใคร
ความหมาย: IP ที่โดน 429 เกิน 10 ครั้งใน 10 นาที จะถูกแบนทั้ง port 80 และ 443 หนึ่งชั่วโมง ตั้ง maxretry สูงกว่า burst พอสมควร ผู้ใช้จริงที่กดซ้ำสองสามทีจะไม่โดน ถ้าใช้ fail2ban กับ SSH อยู่แล้วตาม ตั้งค่า SSH ให้ปลอดภัย ก็เพิ่ม jail นี้เข้าไปได้เลย
ข้อผิดพลาดที่เจอบ่อย
- อยู่หลัง proxy แต่ไม่ตั้ง realip ทุกคำขอมาจาก IP ของ proxy ผู้ใช้ทั้งเว็บแชร์โควตาเดียว และ fail2ban แบน proxy ต้องตั้ง
set_real_ip_fromเป็นช่วง IP ของ proxy ที่ไว้ใจ และreal_ip_headerให้ตรงกับที่ proxy ส่ง (realip มีในแพ็กเกจ nginx ของ Ubuntu) - คิดว่า 5r/m คือนาทีละ 5 แล้วรีเซ็ต จริง ๆ คือทุก 12 วินาที ผู้ใช้ที่พิมพ์รหัสผิดติดกันหลายครั้งจะโดนเร็วกว่าที่คิด ถ้าไม่มี burst
- ไม่ใส่ burst กับ API ที่ frontend ยิงหลายคำขอพร้อมกัน หน้าเว็บเปิดมายิง 8 คำขอใน 50 ms ที่
rate=10r/sไม่มี burst จะผ่านแค่บางคำขอ - ใส่
limit_reqทั้งใน server และ location nginx สืบทอดlimit_reqจากระดับบน ก็ต่อเมื่อ ระดับปัจจุบันไม่มีเลย ใส่ใน location แล้วตัวใน server จะไม่มีผลกับ location นั้น - ปล่อย 503 ไว้ monitoring จะนับว่าเว็บล่ม และ client แยกไม่ออกว่าต้องรอหรือเซิร์ฟเวอร์พัง
- ล็อกบัญชีถาวร ใครก็ล็อกบัญชีคนอื่นได้ ใช้ล็อกชั่วคราวหรือหน่วงเวลาที่โตขึ้น และให้ลืมรหัสผ่านใช้ได้
แหล่งอ้างอิง
- Module ngx_http_limit_req_module — nginx.org
- Module ngx_http_realip_module — nginx.org
- ngx_http_limit_req_module.c — nginx source on GitHub
- RFC 6585 §4: 429 Too Many Requests — RFC Editor
- NIST SP 800-63B-4 §3.2.2 Rate Limiting — NIST
- Authentication Cheat Sheet — OWASP
- nginx-limit-req filter — fail2ban on GitHub
คำถามที่พบบ่อย
burst ไม่ใส่ nodelay ต่างกันยังไง
ไม่ใส่ nodelay คำขอที่เกิน rate แต่ยังไม่เกิน burst จะถูกหน่วงให้ออกตามจังหวะ rate ที่ 5r/m คำขอที่ 6 อาจรอเป็นนาที ผู้ใช้จริงเห็นหน้าค้าง ใส่ nodelay คำขอใน burst ผ่านทันที แต่โควตายังคืนตามจังหวะเดิม หน้า login กับ API จึงมักใช้ nodelay
เว็บอยู่หลัง Cloudflare หรือ load balancer ต้องแก้อะไร
ต้องตั้ง realip module ให้ nginx เห็น IP จริงของผู้ใช้ ไม่อย่างนั้น $binary_remote_addr คือ IP ของ proxy ทุกคนจะใช้โควตาก้อนเดียวกัน และ fail2ban จะแบน IP ของ proxy ทำให้เว็บล่มเอง
ล็อกบัญชีหลังผิด 5 ครั้งดีไหม
ล็อกถาวรทำให้ใครก็ได้ล็อกบัญชีคนอื่นได้ด้วยการพิมพ์รหัสผิด OWASP แนะนำให้ชั่งระหว่างจำนวนครั้ง ช่วงเวลา และระยะล็อก เช่น หน่วงเวลาที่โตขึ้นเรื่อย ๆ หรือล็อกชั่วคราว และให้ลืมรหัสผ่านยังใช้ได้
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ