Bảo mật và rate limit đúng cách
Vì sao đếm theo IP ở Việt Nam là chặn oan hàng trăm người thật, mô hình trần đúng theo phiên, và danh sách tự tấn công app của mình.
Bài này có một bài học đã trả giá hai lần bằng sự cố diện rộng. Nếu bạn chỉ đọc một mục trong cả Cấp 4, hãy đọc mục đầu tiên.
Đếm theo IP là sai ở Việt Nam
Không bao giờ đếm trần theo IP trần cho luồng người thật.
Lý do: mạng 4G Việt Nam dùng NAT cấp nhà mạng (CGNAT) — hàng trăm tới hàng nghìn người dùng chung một địa chỉ IP công cộng. Văn phòng cũng vậy: cả công ty đi ra ngoài bằng một IP.
Hậu quả thật, xảy ra hai lần:
- Lần 1: đặt trần đăng nhập theo IP. Giờ cao điểm, cả trăm nhân viên chia nhau một IP 4G → 429 dây chuyền, không ai đăng nhập được.
- Lần 2: đặt trần cho mọi request chưa có phiên, cũng theo IP. Kết quả tệ hơn: người dùng kẹt ở màn hình tải, app trông như chết.
Người tấn công thật thì không bị ảnh hưởng — họ đổi IP dễ hơn bạn nghĩ. Bạn chặn đúng người dùng của mình.
Mô hình trần đúng
Bốn quy tắc, áp theo thứ tự:
1. Người CHƯA có phiên → không đếm gì, ngoài trần riêng cho cửa đăng nhập.
An toàn được vì mọi đường tốn tài nguyên đều nằm sau tường xác thực. Hàm đọc phiên phải trả null ngay khi không có cookie, không chạm D1 — kẻ bắn phá chỉ nhận lại lỗi 401 rẻ tiền. (Xem mã ở Đăng nhập.)
2. Cửa đăng nhập: đếm theo ip + tên đăng nhập, không theo IP đơn thuần.
Ghép thêm tên đăng nhập làm cho cả nghìn người chung IP vẫn độc lập với nhau, trong khi kẻ dò một tài khoản vẫn bị chặn.
3. Có phiên → khóa đếm theo PHIÊN, không theo IP.
Và miễn đếm cho các đường rẻ hoặc đọc nhiều: ảnh, sự kiện, giờ máy chủ, phiên bản app, kiểm tra sống. Không miễn thì người dùng mở một thư viện ảnh 40 tấm là tự đốt trần của chính mình.
4. Giữ trần chống bot quét: đọc nặng ~60 lượt/phút, ghi ~60 lượt/phút, tính theo phiên. Đó mới là thứ đáng chặn.
const MIEN_DEM = ["/api/anh/", "/api/su-kien", "/api/gio", "/api/phien-ban", "/api/song"];
async function kiemTran(request, env, u) {
const p = new URL(request.url).pathname;
if (MIEN_DEM.some(d => p.startsWith(d))) return null; // miễn
if (!u) return null; // chưa có phiên: không đếm
const ghi = request.method !== "GET";
const khoa = `tran:${u.id}:${ghi ? "ghi" : "doc"}:${Math.floor(Date.now() / 60000)}`;
const n = (await env.PHIEN.get(khoa, { type: "json" })) || 0;
if (n >= 60) return json({ loi: "Thao tac qua nhanh, cho mot chut" }, 429);
await env.PHIEN.put(khoa, JSON.stringify(n + 1), { expirationTtl: 120 });
return null;
}
Đo thật ngày 25/09/2026: cùng một trần 2 lượt/10 giây — lần đo đầu chặn đúng; lần sau đổi namespace và chờ 30 giây thì 10/10 request đều lọt. Cả cú pháp cũ unsafe.bindings lẫn cú pháp mới đều vậy. Nguyên nhân: nó đếm xấp xỉ, theo từng điểm biên, không đếm tập trung.
Kết luận dùng được:
- Chỗ cần trần gần đúng (chống quét chung chung) → binding dùng được.
- Chỗ cần trần thật (cửa đăng nhập) → đếm bằng Durable Object riêng, khóa theo ip + tên đăng nhập, và xóa bộ đếm khi đăng nhập ĐÚNG để người thật gõ sai vài lần không bị phạt oan.
Thêm hai điều khi đi đo trần: curl tuần tự chỉ đạt ~1,3 lượt/giây nên không bao giờ chạm trần cao — phải bắn song song, hoặc hạ trần xuống mới thấy. Và đo ngay sau khi deploy thì luôn thấy lọt hết (cấu hình chưa lan khắp các điểm biên).
Cuối cùng: binding rate limit không chạy trên bản preview. Mọi thay đổi về trần phải test trên bản thật.
Bảng kiểm bảo mật cho app Vibe Code
Mười mục, theo thứ tự thiệt hại từ nặng tới nhẹ:
| # | Mục | Tự kiểm thế nào |
|---|---|---|
| 1 | SQL luôn dùng .bind(), không nối chuỗi |
Tìm toàn bộ dấu backtick có ${ trong câu SQL |
| 2 | Mọi /api/ kiểm quyền ở máy chủ |
Gọi bằng tài khoản thấp nhất → phải 403 |
| 3 | Phân quyền theo dữ liệu, không chỉ vai trò | Đổi id trong đường dẫn → phải 403/404 |
| 4 | Mật khẩu băm PBKDF2 có muối | Xem thẳng trong cơ sở dữ liệu |
| 5 | Cookie HttpOnly + Secure + SameSite |
document.cookie không thấy phiên |
| 6 | Không có khóa bí mật trong mã | Rà cả lịch sử Git |
| 7 | Tải file: chặn cỡ, lọc loại, tự đặt tên | Thử tải .html đổi đuôi, file 20 MB |
| 8 | Thông báo lỗi không lộ chi tiết nội bộ | Gây lỗi, đọc phần trả về |
| 9 | Trần chống dò theo ip+tên, có xóa khi đúng |
Gõ sai 5 lần rồi gõ đúng |
| 10 | Chữ của người dùng hiện ra có thoát HTML | Nhập <img src=x onerror=alert(1)> |
Mục 10 — XSS — đáng nói thêm. Nếu bạn gán chữ của người dùng bằng innerHTML, kẻ xấu nhập mã vào ô "Ghi chú" và mã đó chạy trên máy của người khác, đọc được dữ liệu họ đang xem.
el.textContent = ghiChu; // ĐÚNG — luôn an toàn
el.innerHTML = ghiChu; // SAI — trừ khi đã tự tay làm sạch
Luật đơn giản: mặc định dùng textContent. Chỉ dùng innerHTML khi bạn tự sinh ra chuỗi đó, và không có mảnh dữ liệu người dùng nào trong đó.
Không phải tin tặc quốc tế. Theo những gì tôi gặp:
- Nhân viên cũ vẫn đăng nhập được sau khi nghỉ việc. Hay gặp nhất, và thiệt hại lớn nhất. → Phải có nút khóa tài khoản và việc đó phải thu hồi phiên ngay, không đợi hết hạn.
- Nhân viên xem dữ liệu không thuộc phần mình bằng cách đổi số trong đường dẫn. Thường không ác ý — tò mò. Nhưng khi lương hoặc doanh số của người khác lộ ra thì thành chuyện lớn trong công ty.
- Bot quét tự động tìm cơ sở dữ liệu mở, tìm
.env, thử các đường quản trị phổ biến. Không nhắm vào bạn, chỉ quét cả Internet. - Rò rỉ qua ảnh chụp màn hình — nhân viên chụp màn hình có dữ liệu khách rồi gửi vào nhóm chat.
Ba mục đầu đều chặn được bằng mục 2 và mục 3 của bảng kiểm. Mục 4 thì không phải vấn đề kỹ thuật — nhưng bạn giảm được bằng cách chỉ hiện dữ liệu cần cho việc đang làm.
Tự tấn công app của mình
Làm trước khi giao cho khách, mỗi lần có tính năng mới. Dùng tài khoản quyền thấp nhất:
# 1. Gọi API admin bằng tài khoản thường → phải 403
curl -i -X DELETE https://app.com/api/don-hang/1 -H "cookie: phien=<phien-nhan-vien>"
# 2. Xem dữ liệu người khác → phải 403/404
curl -i https://app.com/api/don-hang/999 -H "cookie: phien=<phien-nhan-vien>"
# 3. Không cookie → phải 401 (và phải nhanh, không đụng cơ sở dữ liệu)
curl -i https://app.com/api/don-hang
# 4. Kéo cả bảng → phải bị chặn trần số dòng
curl -i "https://app.com/api/don-hang?moi_trang=999999" -H "cookie: phien=<phien>"
# 5. SQL injection → phải trả về rỗng hoặc 400, KHÔNG phải cả bảng
curl -i "https://app.com/api/don-hang?trang_thai=' OR '1'='1" -H "cookie: phien=<phien>"
# 6. Tải file sai loại → phải 415
curl -i -F "anh=@trang.html;type=image/jpeg" https://app.com/api/anh -H "cookie: phien=<phien>"
Nhét chuỗi có dấu thẳng vào curl -d "..." là Windows đổi bảng mã: chữ một dấu rụng dấu, chữ nhiều dấu thành ?. Bạn sẽ tưởng app làm hỏng dữ liệu.
Ghi ra file rồi nạp file:
curl -X POST https://app.com/api/don-hang --data-binary "@don.json" \
-H "content-type: application/json" -H "cookie: phien=<phien>"
Hai việc nên bật ở lớp biên
Ở lớp 1 của sơ đồ, Cloudflare cho bạn hai thứ gần như miễn phí:
- Bot Fight Mode — chặn bot quét tự động. Bật trong Security → Bots.
- Quy tắc WAF cho đường quản trị. Nhưng nhớ bài học CGNAT: nếu chặn theo IP thì chỉ chặn ở những đường mà người dùng thật không đi qua (ví dụ
/admin/khi admin luôn ở văn phòng có IP cố định). Đừng áp lên đường đăng nhập chung.
Thứ làm tôi bắt đầu được là nhận ra: phần lớn bảo mật của app như của tôi chỉ là 3 việc.
- Hỏi "người này có được xem cái này không?" ở máy chủ, mỗi lần.
- Không để mật khẩu và khóa nằm trong mã.
- Có nút khóa tài khoản khi ai đó nghỉ việc.
Ba việc đó chặn gần hết những gì thực sự xảy ra với app doanh nghiệp nhỏ. Còn lại là chuyện của ngân hàng thật.
Và một chuyện tôi làm mỗi quý, mất 15 phút: mở danh sách tài khoản, xóa người đã nghỉ. Không có gì kỹ thuật cả, nhưng nó đóng đúng cái cửa hay bị bỏ ngỏ nhất.
Rà toàn bộ project theo 10 mục dưới đây. Với mỗi vi phạm: ghi file,
dòng, hậu quả cụ thể nếu bị khai thác, và cách sửa nhỏ nhất.
Mục nào không vi phạm thì KHÔNG nhắc tới.
1 SQL nối chuỗi thay vì .bind()
2 Đường /api/ nào thiếu kiểm quyền phía máy chủ
3 Truy vấn nào thiếu ràng buộc theo người sở hữu dữ liệu
4 Mật khẩu lưu không băm, hoặc băm không có muối
5 Cookie phiên thiếu HttpOnly / Secure / SameSite
6 Khóa bí mật, mật khẩu, số điện thoại thật nằm trong mã
7 Đường tải file thiếu chặn cỡ / lọc loại / tự đặt tên
8 Thông báo lỗi trả cho người dùng lộ chi tiết nội bộ
9 Trần chống dò đếm theo IP trần (SAI với mạng 4G Việt Nam)
10 Chữ của người dùng gán bằng innerHTML thay vì textContent
Chỉ đọc mã, không sửa. Sắp theo mức thiệt hại, nặng nhất trước.
- Đủ: bài học CGNAT, mô hình trần 4 quy tắc, cảnh báo binding rate limit không đáng tin, bảng kiểm 10 mục, bộ lệnh tự tấn công, đề rà tự động.
- Đã trả giá thật: hai sự cố 429 diện rộng, và kết quả đo cho thấy binding rate limit đếm không chắc chắn.
- Chưa nói: kiểm thử xâm nhập chuyên nghiệp, tuân thủ pháp lý về dữ liệu cá nhân. Nếu app của bạn giữ dữ liệu cá nhân của nhiều người, hãy tìm người có nghề — đây là ô vàng thật sự.
- Rủi ro còn lại: bảng kiểm này chống được những gì đã biết. Nó không thay được nguyên tắc nền: chỉ thu thập dữ liệu bạn thật sự cần. Dữ liệu không có thì không rò rỉ được.
- Chạy đủ 6 lệnh tự tấn công. Mỗi lệnh ghi lại mã trả về thật.
- Mục nào sai, sửa, rồi chạy lại cho tới khi đúng hết.
- Chạy đề rà tự động, đọc kết quả, sửa nhóm nặng trước.
- Phép thử nhân viên nghỉ việc: khóa một tài khoản đang có phiên hoạt động. Người đó phải mất quyền ngay lập tức, không phải chờ 7 ngày cookie hết hạn. Nếu họ vẫn dùng được, bạn thiếu bước thu hồi phiên — đây là lỗ hổng phổ biến nhất và thiệt hại thật nhất.