Một request từ trình duyệt tới website có thể đi qua nhiều chặng: CDN như Cloudflare, balancer, rồi mới tới minion chạy ứng dụng. Mỗi chặng là một proxy, và mỗi proxy thay địa chỉ nguồn bằng địa chỉ của chính nó. Nếu không cấu hình gì, nginx trên minion thấy mọi request đến từ IP của balancer, còn Laravel coi mọi người dùng là cùng một người. Tài liệu này giải thích vì sao phải lấy đúng IP thật của người dùng, module realip của nginx hoạt động thế nào, cấu hình mẫu cho từng tầng, cách kiểm tra và những lỗi hay gặp.

1. Tổng quan: IP thật bị mất ở đâu

Trong mô hình Cloud VPS ba tầng, balancer nhận kết nối từ Internet rồi mở một kết nối mới tới minion qua mạng private. Với nginx trên minion, bên kết nối trực tiếp là balancer, nên $remote_addr luôn là IP private của balancer, ví dụ 10.0.0.5. IP thật không mất hẳn: balancer chép nó vào header khi chuyển tiếp, như cấu hình trong bài Tầng Balancer: Nginx reverse proxy, SSL offload và định tuyến domain:

# balancer: location / của mỗi site
proxy_set_header X-Real-IP       $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for lấy header X-Forwarded-For mà client gửi lên (nếu có) rồi nối thêm $remote_addr vào cuối. Như vậy IP thật nằm trong header, nhưng nginx của minion mặc định không đọc header đó. Mọi thứ dùng $remote_addr ở minion đều sai: access log, allow/deny, và quan trọng nhất là biến REMOTE_ADDR truyền cho PHP-FPM.

Khi domain đặt sau Cloudflare, có thêm một chặng nữa. Lúc này chính balancer cũng không thấy IP thật: $remote_addr trên balancer là IP của máy chủ Cloudflare gần người dùng nhất.

Người dùng 203.0.113.7
   │
   ▼  Cloudflare thêm CF-Connecting-IP: 203.0.113.7
Cloudflare edge (IP thuộc dải công bố của Cloudflare)
   │
   ▼  $remote_addr = IP Cloudflare
balancer ── X-Forwarded-For: 203.0.113.7, <IP Cloudflare>
   │
   ▼  $remote_addr = 10.0.0.5 (balancer)
minion  ── nếu không cấu hình realip: mọi request "đến từ" 10.0.0.5

Ghi chú: các địa chỉ 203.0.113.x và 198.51.100.x trong bài thuộc dải dành riêng cho tài liệu, chỉ dùng để minh hoạ.

2. Vì sao sai IP thật lại nguy hiểm

Thành phầnDùng IP để làm gìHậu quả khi chỉ thấy IP proxy
Rate limit (limit_req, limit_conn)Đếm request theo từng IPMọi người dùng chung một hạn mức: một bot làm cả site bị 429, hoặc ngược lại, bot ẩn trong đám đông
fail2banBan IP có hành vi xấuBan IP proxy là chặn luôn rất nhiều người dùng thật đi qua proxy đó
Laravel throttleGiới hạn đăng nhập sai, gửi form theo $request->ip()Một người nhập sai mật khẩu nhiều lần, mọi người khác cũng bị khoá đăng nhập
Access log, báo cáoĐiều tra sự cố, thống kêKhông biết ai đang tấn công, không lọc được bot
Ứng dụngLưu IP đơn hàng, chống gian lận, geo-IPDữ liệu vô nghĩa, toàn IP nội bộ

Ví dụ trong bài Chuẩn hoá bảo mật Nginx: security snippet và giới hạn kết nối, limit_req đặt theo $binary_remote_addr trên balancer. Với site đi thẳng từ Internet, đó là IP thật. Với site sau Cloudflare mà balancer chưa có realip, đó là IP Cloudflare, nên một ngưỡng "mỗi IP" thực chất áp lên cả một nhóm người dùng.

3. Cơ chế: module realip của nginx

Module ngx_http_realip_module thay giá trị $remote_addr bằng địa chỉ lấy từ một header, nhưng chỉ khi request đến từ một proxy được tin cậy. Module này có sẵn trong gói nginx của Debian (kiểm tra bằng nginx -V 2>&1 | grep http_realip_module). Ba chỉ thị cần biết:

  • set_real_ip_from: dải địa chỉ được tin. Request từ nơi khác thì header bị bỏ qua, $remote_addr giữ nguyên.
  • real_ip_header: header chứa IP thật, thường là X-Forwarded-For, X-Real-IP hoặc CF-Connecting-IP.
  • real_ip_recursive: khi off (mặc định), lấy địa chỉ cuối cùng trong header. Khi on, đi từ phải sang trái và lấy địa chỉ đầu tiên không thuộc dải tin cậy.

Ví dụ với header X-Forwarded-For: 198.51.100.9, 203.0.113.7, 10.0.0.5 và set_real_ip_from 10.0.0.0/24:

  • real_ip_recursive off: kết quả là 10.0.0.5, vẫn là IP proxy.
  • real_ip_recursive on: bỏ qua 10.0.0.5 vì được tin, dừng ở 203.0.113.7. Địa chỉ 198.51.100.9 ở bên trái bị bỏ qua, và đó là điều đúng: phần bên trái của X-Forwarded-For do client tự gửi, có thể là giả.

Đây là điểm cốt lõi về bảo mật: không bao giờ lấy địa chỉ trái nhất của X-Forwarded-For và không bao giờ đặt set_real_ip_from 0.0.0.0/0. Làm vậy là cho phép bất kỳ ai tự khai IP thật của mình, né rate limit và fail2ban chỉ bằng một header.

4. Cấu hình IP thật trên minion: chỉ tin balancer

Ở minion, proxy duy nhất đáng tin là balancer trong mạng private. Đặt cấu hình trong khối http của /etc/nginx/nginx.conf, trước các dòng include:

http {
    # ...
    # === Real IP từ balancer ===
    set_real_ip_from 10.0.0.0/24;          # dải private của các balancer
    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

Sau khi áp dụng, $remote_addr trên minion là IP thật cho mọi site đi thẳng tới balancer. PHP-FPM nhận đúng REMOTE_ADDR, nên Laravel chạy trên PHP-FPM có $request->ip() đúng mà không cần sửa code. Nên giữ thêm cột "$http_x_forwarded_for" ở cuối log format, để khi điều tra vẫn thấy cả chuỗi proxy gốc.

Đồng bộ cấu hình trên nhiều minion

Khi có nhiều minion, cấu hình realip dễ bị lệch: máy đã có nhưng đặt ở vị trí khác nhau, máy chưa có. Script đồng bộ nên làm đúng một việc trên từng máy:

  1. Kiểm tra nginx có http_realip_module, thiếu thì bỏ qua và ghi lỗi.
  2. Backup nginx.conf, xoá mọi dòng set_real_ip_from, real_ip_header, real_ip_recursive cũ, rồi chèn một khối duy nhất trước dòng include. Nếu chạy trong Groovy của Jenkins, nên xử lý chuỗi bằng Groovy thay vì gửi sed từ xa, vì \/ và \* dễ bị hiểu sai trước khi tới shell.
  3. nginx -t. Lỗi thì chép lại bản backup, đạt thì systemctl reload nginx.

Một chi tiết dễ sót: nếu một khối server tự khai set_real_ip_from riêng, giá trị ở cấp server sẽ thay thế hoàn toàn giá trị kế thừa từ http. Gom về một chỗ trong nginx.conf và không khai thêm ở file cấu hình site giúp tránh trường hợp site này lấy đúng IP thật, site kia không.

Laravel: TrustProxies khi có proxy trong ứng dụng

Site Laravel Octane là trường hợp riêng. Nginx minion proxy_pass tới FrankenPHP trên 127.0.0.1, nên với ứng dụng, bên kết nối là chính máy local. Laravel chỉ đọc X-Forwarded-For và X-Forwarded-Proto khi proxy nằm trong danh sách tin cậy của middleware TrustProxies. Theo hướng dẫn cấu hình trusted proxy của Laravel, chỉ nên tin đúng địa chỉ proxy:

// app/Http/Middleware/TrustProxies.php (Laravel 10 trở về trước)
protected $proxies = ['127.0.0.1'];

// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: ['127.0.0.1']);
})

Tránh đặt $proxies = '*' cho tiện: với site PHP-FPM nhận request trực tiếp từ nginx, nó cho phép client giả header. Vì nginx minion đã lấy đúng IP thật trước khi chuyển tiếp, X-Forwarded-For mà Laravel nhận từ 127.0.0.1 đã có IP thật ở vị trí cuối cùng.

5. Balancer: tin dải IP của Cloudflare

Cấu hình ở minion mới giải quyết được một nửa. Với domain đi qua Cloudflare, nếu balancer không có realip thì $proxy_add_x_forwarded_for nối IP Cloudflare vào cuối header, và minion dừng ở IP Cloudflare vì địa chỉ này không thuộc dải tin cậy. Mọi balancer phục vụ domain sau Cloudflare cần thêm một file riêng:

# /etc/nginx/conf.d/00-cloudflare-realip.conf trên balancer
# Danh sách lấy từ trang IP ranges của Cloudflare - phải cập nhật định kỳ
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... toàn bộ dải IPv4 và IPv6 Cloudflare công bố ...
set_real_ip_from 2400:cb00::/32;

real_ip_header CF-Connecting-IP;

Vì sao dùng CF-Connecting-IP mà không phải X-Forwarded-For: header này chỉ chứa đúng một địa chỉ, do Cloudflare đặt, nên không phải lo chuỗi nhiều IP. Và vì set_real_ip_from chỉ gồm dải của Cloudflare, client nào kết nối thẳng tới balancer rồi tự gửi CF-Connecting-IP giả sẽ bị bỏ qua. Domain không đi qua Cloudflare không bị ảnh hưởng, vì request của chúng không đến từ các dải này.

Sau khi balancer có realip, $remote_addr trên balancer là IP thật, $proxy_add_x_forwarded_for nối IP thật vào cuối header, và minion với cấu hình ở mục 4 tự lấy đúng mà không cần sửa thêm. Danh sách công bố trên trang IP ranges của Cloudflare có thay đổi theo thời gian, nên file này nên được sinh lại định kỳ bằng một tác vụ tự động thay vì chép tay một lần:

#!/bin/sh
# sinh lại file realip từ danh sách Cloudflare, chỉ reload khi nginx -t đạt
OUT=/etc/nginx/conf.d/00-cloudflare-realip.conf
TMP=$(mktemp)
{
  for u in https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6; do
    curl -fsS "$u" | sed 's/^/set_real_ip_from /; s/$/;/'
  done
  echo "real_ip_header CF-Connecting-IP;"
} > "$TMP" || exit 1
cp "$OUT" "$OUT.bak" 2>/dev/null
mv "$TMP" "$OUT"
nginx -t && systemctl reload nginx || cp "$OUT.bak" "$OUT"

6. Kiểm tra IP thật đã đúng chưa

# trên minion: cột đầu phải là IP thật, không phải 10.0.0.x
tail -f /var/log/nginx/example.com.access | awk '{print $1}'

# thử giả header từ máy ngoài: IP ghi nhận phải là IP máy bạn, không phải 198.51.100.1
curl -s -H 'X-Forwarded-For: 198.51.100.1' https://example.com/ -o /dev/null

# trong Laravel (tinker hoặc route tạm thời chỉ bật khi debug)
Route::get('/_ip', fn (Request $r) => [$r->ip(), $r->header('X-Forwarded-For')]);

Với domain sau Cloudflare, nếu log minion vẫn ghi IP thuộc dải Cloudflare, balancer chưa nạp file realip.

7. Lỗi thường gặp và cách xử lý

  • Log vẫn toàn IP balancer: khối realip nằm trong file không được nạp. Kiểm tra bằng nginx -T | grep -n real_ip.
  • IP thật bị thay bằng IP Cloudflare: balancer chưa tin dải Cloudflare hoặc danh sách đã cũ. Sinh lại file theo mục 5.
  • Site này đúng, site kia sai: có khối server tự khai set_real_ip_from, ghi đè cấp http.
  • Client giả được IP thật: dải tin cậy quá rộng hoặc ứng dụng tự đọc phần tử đầu của X-Forwarded-For.
  • Laravel Octane trả 127.0.0.1: thiếu TrustProxies cho 127.0.0.1.

8. Checklist

  • Mỗi chặng proxy chỉ tin chặng ngay trước nó: minion tin balancer, balancer tin Cloudflare, ứng dụng tin nginx local.
  • Cấu hình realip trước khi bật rate limit, fail2ban, throttle: các lớp này vô nghĩa, thậm chí có hại, nếu IP thật sai.
  • Cấu hình ở một chỗ: khối realip nằm trong nginx.conf, không rải vào từng site.
  • Tự động cập nhật dải Cloudflare, chỉ reload khi nginx -t đạt.
  • Thử bằng header giả sau mỗi lần đổi cấu hình proxy.

Bài tiếp theo

Mỗi website một PHP-FPM pool: chấm dứt lỗi max_children. Vì sao một pool www dùng chung cho nhiều site khiến một site chậm kéo cả minion chết theo, và cách tách pool riêng cho từng site.