บทความ › ความปลอดภัย

จำกัดการยิง login และ API ด้วย nginx limit_req: burst, nodelay, 429, fail2ban

ตั้ง rate limit หน้า login และ API ด้วย nginx limit_req ให้ถูกความหมาย burst กับ nodelay คำนวณว่าสคริปต์ยิง 100 ครั้งต่อนาทีผ่านได้กี่ครั้ง และทำไมต้องจำกัดต่อบัญชีด้วย

กลาง อ่าน 12 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 ลดลงตามเวลา ด้วยอัตรา rate nginx นับละเอียดระดับมิลลิวินาที 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_statusstatus ที่ส่งตอนปฏิเสธ ค่าเริ่มต้น 503

ค่าเริ่มต้น 503 แปลว่า “เซิร์ฟเวอร์ไม่พร้อม” ซึ่งผิดความหมาย ควรตั้งเป็น 429 Too Many Requests ตาม RFC 6585 ที่บอกว่าเป็นคำขอเยอะเกินจากฝั่ง client และ cache ห้ามเก็บ

ตัวอย่างคำนวณ: สคริปต์ยิง 100 ครั้งต่อนาที

ตั้ง rate=5r/m burst=5 nodelay แล้วมีสคริปต์จาก IP เดียวยิงหน้า login 100 ครั้งต่อนาที คือทุก 0.6 วินาที

  1. โควตาคืนทุก 12 วินาที ใน 0.6 วินาทีจึงคืนได้ 0.6 ÷ 12 = 0.05 ครั้ง
  2. คำขอแรก (t=0) ผ่าน excess = 0
  3. คำขอที่ 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)
  4. คำขอที่ 7 (t=3.6) excess จะเป็น 5.7 เกิน 5 ได้ 429
  5. จะผ่านอีกครั้งต้องให้ excess ลดจาก 4.75 เหลือไม่เกิน 4 คือลด 0.75 ครั้ง = 0.75 × 12 = 9 วินาที หลัง t=3.0 คำขอถัดไปที่มาหลังจุดนั้นคือ t=12.6 ผ่าน
  6. จากนั้นผ่าน 1 ครั้งทุก ~12 วินาที (t=24.6, 36.6, 48.6, …)
ช่วงผ่านได้ 429
นาทีแรก1090
ทุกนาทีถัดไป595
ทั้งวัน (ต่อ IP)≈ 5 × 1,440 = 7,200ที่เหลือทั้งหมด

ตัวเลขนี้ตรงกับการจำลองสูตรใน source ของ nginx ทีละมิลลิวินาที ข้อสรุปคือ ต่อ IP เดียว สคริปต์ถูกตัดเหลือ 5% แต่ถ้าผู้โจมตีมี 1,000 IP ก็ยังลองได้ 7,200 × 1,000 = 7.2 ล้านครั้งต่อวัน limit ที่ nginx จึงเป็นแค่ชั้นแรก

0–3 วิผ่าน 6 ครั้ง (1 + burst 5)3.6–12 วิได้ 429 ทั้งหมด12.6 วิผ่าน 1 ครั้งหลังจากนั้นผ่าน 1 ครั้งทุก ~12 วิ
rate=5r/m burst=5 nodelay กับสคริปต์ 100 ครั้ง/นาที: นาทีแรกผ่าน 10 จาก 100

คอนฟิก 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 แยกไม่ออกว่าต้องรอหรือเซิร์ฟเวอร์พัง
  • ล็อกบัญชีถาวร ใครก็ล็อกบัญชีคนอื่นได้ ใช้ล็อกชั่วคราวหรือหน่วงเวลาที่โตขึ้น และให้ลืมรหัสผ่านใช้ได้

แหล่งอ้างอิง

คำถามที่พบบ่อย

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 แนะนำให้ชั่งระหว่างจำนวนครั้ง ช่วงเวลา และระยะล็อก เช่น หน่วงเวลาที่โตขึ้นเรื่อย ๆ หรือล็อกชั่วคราว และให้ลืมรหัสผ่านยังใช้ได้

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Securityกลาง

รับ Webhook ให้ปลอดภัย ตรวจ HMAC กัน replay ตอบเร็ว และกัน event ซ้ำ

endpoint รับ webhook เปิดให้ทั้งโลกยิงได้ ตรวจลายเซ็น HMAC บน raw body แบบ constant-time เช็กเวลากัน replay ตอบ 2xx ทันที แล้วกัน event ซ้ำและสลับลำดับ

อ่าน 12 นาที
#Securityเริ่มต้น

Security headers ห้าบรรทัดที่ทุกเว็บควรมี และกับดัก nginx ที่ทำให้หายเงียบ ๆ

header ห้าตัวที่กันการโจมตีพื้นฐานของเว็บได้ แต่ละตัวทำอะไร ใส่ใน nginx ยังไง และกับดัก add_header ที่ทำให้ header หายไปเฉพาะไฟล์บางประเภท พร้อมวิธีตรวจจากของจริง

อ่าน 8 นาที
#Securityเริ่มต้น

เช่าเซิร์ฟเวอร์มาใหม่ ตั้งค่า SSH ให้ปลอดภัยใน 7 ขั้นตอน

ทำทันทีหลังได้เซิร์ฟเวอร์ Ubuntu ใหม่ สร้างผู้ใช้ ใช้คีย์แทนรหัสผ่าน ปิดล็อกอิน root เปิดไฟร์วอลล์ fail2ban และอัปเดตอัตโนมัติ พร้อมกับดักที่หลายคนพลาด

อ่าน 4 นาที