Đang tải…
Đang tải…
Chặn theo IP nghĩa là một văn phòng dùng chung NAT sẽ cùng nhau tiêu hết hạn mức. Và middleware thì không nhìn thấy Server Action.

Giới hạn tốc độ theo IP là cách ai cũng làm đầu tiên, vì IP là thứ luôn có sẵn. Nó cũng sai theo hai hướng cùng lúc.
Một văn phòng đi qua chung một NAT thì cả toà nhà nhìn từ phía server chỉ là một địa chỉ. Người đầu tiên bấm nút tiêu hết hạn mức, người thứ hai bị chặn dù chưa làm gì. Ở chiều ngược lại, một người có ý đồ xấu thay IP còn dễ hơn bạn đọc xong đoạn này.
Nếu người dùng đã đăng nhập thì bạn có một thứ tốt hơn IP nhiều: id của họ. Nó không đổi khi họ chuyển từ wifi sang 4G, và nó không dùng chung với đồng nghiệp ngồi bàn bên.
export async function personIdentifier(userId) {
if (userId) return `user:${userId}`;
return `ip:${await getClientIp()}`;
}
Bốn dòng, và nó là khoá mà mọi route API hướng người dùng trên site này dùng để phân xô. Đã đăng nhập thì tính theo tài khoản, chưa đăng nhập thì lùi về IP. Lưu lượng ẩn danh vẫn bị chặn trần, còn người dùng thật không bị vạ lây bởi hàng xóm.
Tiền tố user: và ip: không phải để cho đẹp. Không có nó, một id người dùng trùng chuỗi với một địa chỉ IP sẽ dùng chung một xô, và bạn sẽ mất một buổi chiều để hiểu vì sao.
export async function getClientIp() {
const h = await headers();
return (
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ??
"127.0.0.1"
);
}
Đứng sau proxy thì x-forwarded-for là một danh sách, không phải một giá trị. Mỗi proxy trên đường đi nối thêm địa chỉ vào cuối. Phần tử đầu tiên là client gốc, và đó là lý do có split(",")[0].
Lấy nhầm phần tử cuối thì bạn đang giới hạn tốc độ chính hạ tầng của mình, và mọi request sẽ chia nhau một xô duy nhất.
Đây là chỗ mình mất nhiều thời gian nhất, vì nó không có triệu chứng nào cả.
Middleware của dự án khớp theo đường dẫn, và nó bao mọi Route Handler dưới /api/*. Trông như đã phủ hết bề mặt ghi dữ liệu. Nhưng Server Action không POST tới /api/: nó POST tới chính đường dẫn của trang đang hiển thị. Một action gửi form liên hệ nằm ở trang /vi/contact sẽ POST tới /vi/contact.
Nghĩa là đường dẫn /api/* được bảo vệ, còn action thì hoàn toàn mở, và bảng cấu hình middleware trông vẫn rất đầy đủ.
Cách sửa là gọi hàm giới hạn ngay bên trong action, chứ không ở tầng định tuyến:
const { success } = await rateLimit("contact", {
tokens: 3,
window: "1 h",
identifier: await personIdentifier(user?.id),
});
if (!success) return { error: "Bạn gửi hơi nhanh. Thử lại sau nhé." };
Khi biến môi trường của Redis không có, hàm này trả về cho qua thay vì chặn. Đó là một lựa chọn, và nó không đúng cho mọi endpoint.
Với form liên hệ hay nút thả cảm xúc, hỏng thì mở là đúng: mất Redis mà làm sập luôn tính năng thì bạn vừa biến một sự cố nhỏ thành một sự cố lớn. Máy dev cũng không cần dựng Redis chỉ để chạy thử.
Với những thứ khác thì ngược lại. Job chạy theo lịch trên site này xác thực bằng một biến môi trường, và nó hỏng thì đóng: thiếu biến đó thì endpoint trả về 503 chứ không tự biến thành một endpoint công khai. Câu hỏi cần đặt cho từng endpoint là: nếu tầng bảo vệ này biến mất, thứ còn lại là một tính năng bị hỏng, hay một cánh cửa mở?
Chưa có bình luận nào — hãy là người đầu tiên!