Một cấu hình fastcgi_cache chạy tốt trên site nội dung thuần rất dễ bị chép sang site khác. Với một site thương mại điện tử có giỏ hàng, chính cấu hình đó có thể gây hai lỗ hổng: một cái làm rò session giữa những khách không quen biết nhau, một cái có thể phát trang xác nhận đơn hàng, gồm tên, số điện thoại, địa chỉ của khách này, cho người truy cập tiếp theo. Tài liệu này mô tả cách tìm ra hai lỗ hổng đó bằng một buổi kiểm tra trước khi bật cache, vì sao cấu hình an toàn ở site trước lại gây rò session ở site sau, và nên làm gì thay thế.
Cơ chế Set-Cookie làm nginx từ chối cache và hai cách xử lý có trong bài fastcgi_cache và Set-Cookie: giảm TTFB mà không rò session. Bài này đi tiếp từ đó.
Bối cảnh: một playbook, nhiều loại site
Playbook cho site nội dung: thêm nodelay cho rate limit, bật config:cache và route:cache, rồi bật fastcgi_cache theo "cách 1": bypass với POST, /admin, query string, request đã có cookie phiên, và cho nginx bỏ qua header Set-Cookie. Cách 1 được chấp nhận ở site nội dung vì trang công khai không phụ thuộc session, nên không có gì để rò session.
Site thương mại điện tử khác hẳn: giỏ hàng khách vãng lai, yêu thích, so sánh sản phẩm đều nằm trong session. Trước khi chép playbook, cần một buổi kiểm tra rò session riêng.
Dấu hiệu cần cảnh giác
- Mọi request GET tới trang công khai, kể cả trang danh mục khi chưa đăng nhập, đều nhận hai header
Set-Cookie:XSRF-TOKENvà cookie session. - Layout dùng chung nhúng
csrf_token()vào HTML. - Danh sách route có nhiều trang GET, không query string, mang dữ liệu riêng của từng khách.
Quy trình kiểm tra
Bước 1: đi vòng qua lớp bảo vệ phía trước
Nếu domain nằm sau một lớp chống bot có thử thách (CAPTCHA, JS challenge), request từ script nhận trang thử thách chứ không chạm tới balancer. Mọi phép đo và phép thử rò session vì vậy phải gọi thẳng IP nội bộ của minion kèm header Host.
Bước 2: thử rò session bằng hai cookie jar
Nếu kế hoạch bật cache chỉ thêm fastcgi_ignore_headers ... Set-Cookie mà không có fastcgi_hide_header Set-Cookie, header Set-Cookie chứa session ID và XSRF token thật sẽ được lưu cùng HTML. Phép thử dùng hai cookie jar giả lập hai khách lạ gọi cùng một URL, trên môi trường thử:
curl -s -c /tmp/jar1.txt -D - -o /dev/null -H "Host: site-c.example" http://10.0.0.216/khuyen-mai/
curl -s -c /tmp/jar2.txt -D - -o /dev/null -H "Host: site-c.example" http://10.0.0.216/khuyen-mai/
diff /tmp/jar1.txt /tmp/jar2.txt # giống hệt session id → cache đang gây rò sessionDiễn biến rò session với khách thật nếu bật cache theo cách 1 trên site có giỏ hàng:
- Khách thứ nhất chưa có cookie, request đi vào PHP, Laravel tạo session mới và trả
Set-Cookie. Nginx lưu cả trang lẫn header. - Trong thời gian cache còn hạn, khách thứ hai, thứ ba cũng chưa có cookie, nhận đúng bản cache đó, tức nhận cùng một session ID.
- Từ request kế tiếp, cả ba đã có cookie nên bị bypass cache, đi thẳng vào PHP, và Laravel coi họ là một người: chung giỏ hàng, chung danh sách yêu thích, chung CSRF token.
Rò session kiểu này không cần ai tấn công. Nó xảy ra tự nhiên ở mọi site mà trang công khai có đọc ghi session. Đây là session fixation theo nghĩa OWASP mô tả, chỉ khác là kẻ "cấy" session lại chính là cache của mình.
Bước 3: tìm trang GET có thể biến rò session thành lộ dữ liệu
Điều kiện $skip_cache của playbook là một danh sách loại trừ. Rà route của module thương mại điện tử (php artisan route:list --method=GET) thường thấy các trang sau vẫn đủ điều kiện được cache:
| Đường dẫn | Nội dung | Hậu quả nếu bị cache |
|---|---|---|
/cart, /checkout | Giỏ hàng, thanh toán | Khách sau thấy giỏ hàng của khách trước |
/order-success/{orderID} | Xác nhận đơn: tên, số điện thoại, địa chỉ, sản phẩm đã mua | Lộ dữ liệu cá nhân cho người mở cùng URL |
/wishlist-product, /compare-product | Danh sách riêng của khách | Trộn danh sách giữa các khách |
Chỉ cần một bot dò URL đơn hàng theo ID, hoặc khách mở lại link xác nhận từ máy khác chưa có cookie, là rò session chuyển thành lộ dữ liệu cá nhân. Với site có luồng mua hàng, danh sách loại trừ luôn có nguy cơ sót. Phải đổi sang danh sách cho phép: chỉ cache đúng những mẫu URL nội dung công khai đã biết là an toàn.
Bước 4: kiểm tra ứng dụng đã có cache riêng chưa
Nhiều site Laravel đã bật full-page cache ở tầng ứng dụng (ví dụ Spatie ResponseCache lưu trên Redis), như trong bài Tăng tốc website với Laravel ResponseCache, Redis và Supervisor. Cache tầng ứng dụng không gây rò session ở đúng những điểm vừa nêu:
- Không cache cho người đã đăng nhập.
CsrfTokenReplacerthay CSRF token trong HTML đã cache bằng token của phiên người đang xem. Nginx trả nguyên byte đã lưu nên không làm được việc này.- Các middleware session, CSRF, mã hoá cookie chạy trước bước trả cache, nên mỗi khách vẫn nhận cookie của riêng mình.
Cái giá là mỗi request vẫn phải khởi động framework. Hướng "bỏ session khỏi route công khai để nginx cache an toàn" cũng thường không khả thi, vì giỏ hàng, yêu thích, ngôn ngữ, tiền tệ đều đọc ghi session ngay trên trang danh mục và sản phẩm.
Nguyên nhân gốc
- Playbook được chép từ site nội dung sang site thương mại điện tử mà không mang theo điều kiện làm nó an toàn: trang công khai không phụ thuộc session.
- Tư duy danh sách loại trừ cho một quyết định bảo mật. Mỗi route mới của module mua hàng là một chỗ có thể sót.
- Cache tầng nginx không hiểu session. Khi đã cho nó bỏ qua
Set-Cookie, nó phát nguyên header đó cho người khác, và rò session là hệ quả tất yếu.
Khắc phục
Làm bước an toàn, không có nguy cơ rò session, trước
Với site có giỏ hàng, không bật cache nginx ngay. Làm trước các bước không đụng tới session hay cookie:
- Sửa mọi chỗ gọi
env()ngoài thư mục config sangconfig(), sửa lỗi trùng tên route, rồi bậtconfig:cache. Riêng bước này thường giảm TTFB đáng kể. - Rà view tự điền sẵn thông tin đăng nhập từ
env()(thường sót lại từ lúc phát triển) và xoá đi, để không bao giờ lộ credential ra mã nguồn trang. - Nếu ứng dụng nạp route theo User-Agent (bộ desktop và bộ mobile riêng),
route:cachechạy ở CLI chỉ thấy một bộ. Cache hai bộ ra hai file riêng và chọn file theo User-Agent khi ứng dụng khởi động. - Nối việc xoá cache tầng ứng dụng vào sự kiện lưu của model (bài viết, đánh giá, danh mục), rồi mới nâng thời hạn cache.
Nếu vẫn cần cache ở tầng nginx
Điều kiện tối thiểu để không rò session là danh sách cho phép, cộng ẩn Set-Cookie bằng fastcgi_hide_header trên đúng phần được cache. Vì fastcgi_hide_header là directive tĩnh theo location, phần được cache phải có location riêng và gọi thẳng index.php, không qua try_files (sau try_files request được xử lý lại và rơi về location PHP chung):
location ~ ^/(khuyen-mai|san-pham|tin-tuc)(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass unix:/run/php/php8.2-fpm-site-c-example.sock;
set $skip_cache 0;
if ($request_method != GET) { set $skip_cache 1; }
if ($http_cookie ~* "(sitec_session|XSRF-TOKEN)") { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
fastcgi_hide_header Set-Cookie;
fastcgi_cache sitec_cache;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache-Status $upstream_cache_status always;
}
# mọi đường dẫn khác: location PHP chung, không cacheLưu ý: trang được cache theo cách này không mang cookie, nên mọi nút AJAX POST trên trang (thêm giỏ hàng, yêu thích) sẽ gặp lỗi 419 vì thiếu session. Chỉ áp cho trang thật sự không có thao tác phụ thuộc session. Nếu không tách được, cache tầng ứng dụng là lựa chọn đúng.
Lỗi hay gặp khi viết danh sách cho phép bằng if: đặt một dòng kiểu if ($request_method = GET) { set $skip_cache 0; } sau các mẫu URL, khiến mọi GET đều được mở cache trở lại. Thứ tự đúng là mặc định không cache, mở cho mẫu an toàn, rồi đóng lại cho mọi điều kiện rủi ro.
Rà nguy cơ rò session ở site đã bật cache theo cách cũ
Nếu đã có site chạy fastcgi_cache theo cách 1 trước khi nhận ra rủi ro, cần rà ngay theo hai câu hỏi: trang công khai có đọc ghi session không, và có trang GET nào mang dữ liệu riêng không. Câu trả lời là "có" thì tắt cache nginx cho site đó cho tới khi chuyển sang danh sách cho phép.
Checklist chống rò session trước khi bật cache trang
- Liệt kê mọi route GET và đánh dấu trang nào mang dữ liệu riêng của người xem. Có giỏ hàng, đơn hàng, tài khoản thì mặc định coi site là có nguy cơ rò session.
- Tìm trong layout các thành phần đọc session:
csrf_token(), số lượng giỏ hàng, trạng thái đăng nhập, ngôn ngữ, tiền tệ. - Kiểm tra ứng dụng đã có cache tầng ứng dụng chưa. Nếu có, ưu tiên tối ưu nó thay vì chồng thêm cache nginx.
- Dùng danh sách cho phép, không dùng danh sách loại trừ.
- Sau khi bật, chạy phép thử rò session bằng hai cookie jar, và mở thử trang xác nhận đơn hàng khi không có cookie, phải nhận
BYPASShoặc không có header cache.
Bài học
- An toàn của một cấu hình nằm ở giả định của nó. Cách 1 an toàn ở site nội dung vì session của khách vãng lai luôn rỗng. Mang sang site có giỏ hàng, giả định đó sai và cùng cấu hình trở thành lỗi rò session.
- Kiểm tra trước khi bật rẻ hơn hậu kiểm sau sự cố. Một buổi đọc route và hai lệnh curl đủ chặn một vụ rò session và lộ dữ liệu khách.
- Bước an toàn trước, bước rủi ro sau.
config:cachevà cache route mang lại phần lớn cải thiện mà không đụng tới session. - Mỗi rủi ro rò session phát hiện ra phải thành việc cụ thể, có người nhận và hạn xử lý.
Bài tiếp theo
CPU 100% ảo do hypervisor: khi top không nói thật. Khi nhiều Cloud VPS cùng báo CPU 100% một lúc mà không tiến trình nào ăn CPU, và cách chứng minh hypervisor đã giữ máy ảo.
