Bài viết gần đây
Trang chủ → Bài Viết → Bảo mật REST API: 14 lỗ hổng tôi tìm thấy trong một hệ thống thật và cách sửa hết
| Bảo mật REST API: 14 lỗ hổng tôi tìm thấy trong một hệ thống thật và cách sửa hết
Được viết bởi Đặng Trí Thanh vào ngày 01/10/2026 lúc 08:37 | 12 lượt xem
Bài viết thuộc loạt case study Vietnam Nails — hệ thống đặt lịch thật đang chạy cho một tiệm nail tại Helsinki. Dành cho học viên khóa Lập trình NodeJS và bất kỳ ai đang vận hành backend cho doanh nghiệp nhỏ.
Tóm tắt cho người bận
- Tôi viết một bộ test bảo mật cho hệ thống của chính mình và nó tìm ra 14 lỗ hổng, trong đó 6 mức nghiêm trọng.
- Lỗi nặng nhất: ai cũng nạp được tiền vào ví khách mà không cần đăng nhập — đã kiểm chứng số dư đổi từ 10,00 € thành 11,00 €.
- Lỗi nguy hiểm nhất: khoá ký token quản trị nằm ngay trong mã nguồn, nên tôi tự ký được token và đọc trọn tin nhắn riêng của khách.
- Nguyên nhân gốc của phần lớn lỗi chỉ là một chữ: 141 endpoint nhưng chỉ 41 chỗ kiểm tra quyền (21 dùng
requireAdmin, 20 dùngrequireOwner). - Sau khi sửa, số phát hiện giảm từ 14 xuống còn 6 — sạch hoàn toàn nhóm lỗi phân quyền.
- Nhưng vẫn còn 1 lỗi nghiêm trọng chưa xử lý, và tôi để nguyên nó đỏ trong bộ test thay vì báo xanh cho oai.
- Bài có sẵn checklist 12 điểm để bạn tự rà hệ thống của mình trong khoảng một giờ.
Mục lục
- Vì sao backend hay bị bỏ quên
- Nhóm 1 — Thiếu kiểm tra quyền
- Nhóm 2 — Trả thừa dữ liệu
- Nhóm 3 — Leo thang đặc quyền
- Nhóm 4 — Xác thực và chiếm tài khoản
- Nhóm 5 — Chèn SQL, gửi thư, cấu hình
- Sửa xong thì đo lại thế nào
- Checklist 12 điểm tự kiểm tra
- Câu hỏi thường gặp
1. Vì sao backend hay bị bỏ quên
Có một nghịch lý dễ thấy ở hầu hết dự án nhỏ: giao diện được kiểm tra rất kỹ, còn backend thì gần như không ai kiểm tra.
Lý do rất hợp lý về mặt cảm giác: giao diện là thứ nhìn thấy được, bấm thử là biết đúng sai. Còn API thì “chỉ có app của mình gọi vào” — nên mặc định là an toàn. Nhưng đó chính là điểm sai. Mọi endpoint đều là một cánh cửa công khai trên internet. Không có chuyện “chỉ app mình gọi” — bất kỳ ai biết đường dẫn đều gọi được, và đường dẫn thì nằm trong chính file JavaScript gửi xuống trình duyệt khách.
Trong dự án tiệm nail này, giao diện khách có khoảng 15 màn hình và đã được thử tay nhiều vòng. Backend có 141 endpoint. Số endpoint gấp gần mười lần số màn hình — nghĩa là phần lớn backend không có giao diện nào đại diện, và vì thế không ai bấm thử bao giờ.
| Hạng mục | Con số | Ý nghĩa |
|---|---|---|
| Endpoint | 141 | Mỗi cái là một cánh cửa |
| Route có kiểm tra quyền | 41 | Chỉ 29% (21 requireAdmin + 20 requireOwner) |
| Màn hình giao diện khách | ~15 | Phần lớn endpoint không có giao diện |
| Lỗ hổng tìm được | 14 | 6 nghiêm trọng, 3 cao, 3 trung bình, 2 thấp |

Điểm đáng chú ý: tôi không tìm ra 14 lỗ hổng bằng cách đọc code hàng nghìn dòng. Tôi tìm ra bằng cách viết một bộ test tự động rồi để nó chạy. Máy không biết mệt và không bỏ sót endpoint nào vì đoán là “chắc cái này an toàn”.
2. Nhóm 1 — Thiếu kiểm tra quyền (nguyên nhân gốc)
Đây là nhóm lỗi nghiêm trọng nhất, và cũng là nhóm dễ sửa nhất. Vấn đề không phải là viết sai logic kiểm tra quyền — mà là quên gọi hàm kiểm tra quyền.
Trong Express, một route quản trị thiếu kiểm tra quyền trông hoàn toàn bình thường khi đọc code. Nó chỉ thiếu một dòng:
app.patch("/api/users/:id/wallet", async (req, res) => {
// THIẾU: if (!requireAdmin(req)) return res.status(401).end();
try {
const { amount, type } = req.body;
// ... cộng/trừ tiền trong ví khách
} catch (error) {
res.status(400).json({ error: "Failed to update wallet" });
}
});
Dòng bị thiếu chỉ khoảng 40 ký tự. Nhưng hệ quả thì không nhỏ chút nào.
Cách rà soát trong 10 phút
Bạn có thể đếm ngay mức độ phủ của việc kiểm tra quyền bằng hai lệnh:
# Tổng số endpoint
grep -cE 'app\.(get|post|put|patch|delete)\(' server/routes.ts
# → 141
# Số chỗ gọi hàm kiểm tra quyền (admin và chủ tiệm)
grep -c 'requireAdmin(req)' server/routes.ts # → 21
grep -c 'requireOwner(req)' server/routes.ts # → 20
Với dự án này: 141 endpoint nhưng chỉ 41 chỗ kiểm tra quyền (29%). Con số sau không nhất thiết phải bằng con số trước — nhiều endpoint phải công khai thật (đăng nhập, đăng ký, danh sách dịch vụ). Nhưng khi tỉ lệ phủ chỉ khoảng một phần ba, gần như chắc chắn có route quản trị bị bỏ quên.
Sau khi sửa xong, hai con số này là 140 endpoint và 73 chỗ kiểm tra quyền (52%) — và endpoint giảm đi 1 vì tôi xoá hẳn một route tạo tài khoản quản trị có mật khẩu cứng (mục 6.3).
Cách sửa: hai lớp, không phải một
Lớp 1 — thêm kiểm tra vào từng route. Cách này rõ ràng và quyền kiểm tra đi kèm route, nhưng dễ quên khi thêm route mới.
Lớp 2 — lưới trung tâm theo tiền tố đường dẫn. Mọi thứ dưới /api/admin/* đều phải có token, trừ một danh sách trắng nhỏ các route cần gọi được trước khi đăng nhập:
const ADMIN_PUBLIC_PATHS = new Set([
"/api/admin/login",
"/api/admin/2fa-status",
"/api/admin/verify-2fa",
"/api/admin/resend-2fa",
"/api/admin/forgot-password",
"/api/admin/reset-password",
]);
app.use((req, res, next) => {
const path = (req.originalUrl.split("?")[0] || "").replace(/\/+$/, "");
if (!path.startsWith("/api/admin")) return next();
if (ADMIN_PUBLIC_PATHS.has(path)) return next();
if (!requireAdmin(req)) {
return res.status(401).json({ error: "Cần đăng nhập quản trị" });
}
next();
});
Vì sao phải làm cả hai lớp? Vì lưới trung tâm chỉ bảo vệ những gì nằm dưới /api/admin/. Rất nhiều endpoint quản trị của dự án này lại nằm ở đường dẫn trung tính — ví dụ PATCH /api/users/:id/wallet hay GET /api/transactions. Nhìn đường dẫn thì không đoán được cái nào là quản trị. Chỉ có đọc code mới biết.
Bài học xương máu: đừng quên phía frontend
Đây là phần tôi suýt làm hỏng hệ thống. Sau khi thêm kiểm tra quyền ở backend, tôi mở trang quản trị thì mọi thao tác đều lỗi 401. Lý do: file gọi API phía frontend không hề gửi header Authorization. Trước đó backend không kiểm tra nên không ai phát hiện.
Cách sửa hiệu quả nhất là gắn token ở một chỗ duy nhất, thay vì sửa hàng trăm lời gọi rải rác. Dự án đã có sẵn một shim bọc window.fetch để đổi domain API, nên chỉ cần thêm một việc nữa vào đó:
function addAdminToken(url, init) {
const token = localStorage.getItem("adminToken");
if (!token || !isOurApiUrl(url)) return init;
const headers = new Headers(init?.headers);
if (headers.has("Authorization")) return init; // tôn trọng header có sẵn
headers.set("Authorization", `Bearer ${token}`);
return { ...init, headers };
}
Chỉ gắn khi trong máy đang có token quản trị — nên hành vi của khách thường không đổi một chút nào. Và vì đặt ở một chỗ, mọi lời gọi API trong toàn bộ ứng dụng đều được bảo vệ, kể cả những lời gọi viết sau này.
3. Nhóm 2 — Trả thừa dữ liệu
Đây là lỗi tôi thấy ở gần như mọi dự án, kể cả dự án của những người có kinh nghiệm. Nó đến từ một thói quen rất tự nhiên: lấy bản ghi từ database rồi trả thẳng ra API.
// Cách viết gây lỗi
app.get("/api/users", async (req, res) => {
const users = await storage.getAllUsers();
res.json(users); // trả về NGUYÊN bản ghi
});
Nghe thì vô hại. Nhưng bảng users có 17 cột, trong đó có những cột không bao giờ được ra khỏi máy chủ:
| Cột | Nếu bị trả ra thì sao |
|---|---|
password (hash bcrypt) |
Kẻ tấn công có sẵn dữ liệu để dò mật khẩu offline, không cần chạm vào máy chủ nữa |
email_verification_token |
Giả mạo được link xác thực email của bất kỳ khách nào |
password_reset_token |
Chiếm tài khoản — chỉ cần khách vừa bấm “quên mật khẩu” là token có giá trị |

Trong lần đo thật, GET /api/users trả về HTTP 200 kèm hash mật khẩu và token xác thực email đang có giá trị — và endpoint này khi đó không cần đăng nhập. Một request duy nhất lấy trọn danh sách khách hàng.
Cách sửa: hàm lọc trường, và phải lọc theo giá trị
Đúng cách là viết một hàm chuyển đổi bản ghi nội bộ thành dữ liệu công khai, rồi bắt buộc mọi endpoint trả hồ sơ khách phải đi qua nó:
function publicUser(user) {
const {
password,
emailVerificationToken,
passwordResetToken,
passwordResetExpiresAt,
...rest
} = user;
return { ...rest, hasPassword: !!password };
}
Giao diện vẫn cần biết khách đã đặt mật khẩu hay chưa — nên trả về cờ hasPassword thay vì chính cái hash. Đây là nguyên tắc quan trọng: trả câu trả lời cho câu hỏi, đừng trả dữ liệu thô để phía client tự suy luận.
Chú ý một cái bẫy khi viết test cho phần này. Lần đầu tôi kiểm tra bằng cách tìm chuỗi "password" trong response — và test báo sai, vì response hợp lệ có trường hasPassword, cũng chứa chuỗi đó. Phải kiểm tra giá trị, không kiểm tra tên trường.
// Đúng: kiểm tra GIÁ TRỊ
expect(!/\$2[aby]\$\d\d\$/.test(responseText)); // không có hash bcrypt
expect(!body.password); // không có trường password mang giá trị
expect(!body.passwordResetToken); // token không được lộ
4. Nhóm 3 — Leo thang đặc quyền
Nhóm này là hậu quả trực tiếp của nhóm 1, nhưng hậu quả cụ thể thì nghiêm trọng hơn nhiều: không cần đăng nhập vẫn thay đổi được dữ liệu.
Tôi kiểm chứng bằng một khách test riêng, không đụng vào dữ liệu thật của tiệm. Trước khi gọi, số dư ví là 10,00 €. Sau một request duy nhất không kèm bất kỳ thông tin xác thực nào:
| Endpoint | Kết quả đo được | Hậu quả nếu bị lợi dụng |
|---|---|---|
PATCH /api/users/:id/wallet |
HTTP 200, ví 10,00 € → 11,00 € | Tự nạp tiền vô hạn vào ví, rồi dùng để trả cho dịch vụ thật |
POST /api/users/:id/points |
HTTP 200 | Tự cộng điểm thưởng, đổi thành tiền giảm giá |
PUT /api/users/:id |
HTTP 200 — đổi được tên khách | Sửa hồ sơ người khác, đổi số điện thoại |
DELETE /api/categories/:id |
HTTP 204 | Xoá danh mục dịch vụ, làm hỏng bảng giá |
PATCH /api/settings |
HTTP 200 | Đổi cấu hình chung của tiệm |
Điểm chung của cả năm endpoint: đường dẫn không hề gợi ý đây là chức năng quản trị. Không có chữ admin nào trong đó. Kẻ tấn công không cần đọc code — chỉ cần thử hàng loạt đường dẫn quen thuộc là tìm ra.
Vì sao lỗi này hay lọt qua khâu kiểm thử
Vì khi thử tay, ta luôn thử ở trạng thái đã đăng nhập. Đăng nhập vào admin rồi bấm “nạp ví cho khách” — thành công. Không ai thử lại đúng thao tác đó sau khi đăng xuất. Đây là loại lỗi mà chỉ kiểm thử tự động có hệ thống mới bắt được.
Và cái giá thì không nhỏ: đây là tiền thật. Khách nạp tiền vào ví bằng chuyển khoản, và số dư ví được dùng để thanh toán dịch vụ. Một lỗ hổng cho phép sửa số dư là một lỗ hổng cho phép in tiền.
5. Nhóm 4 — Xác thực và chiếm tài khoản
Nếu nhóm 3 là nghiêm trọng, thì nhóm 4 là lỗi khiến tôi phải dừng lại làm ngay trong đêm.
5.1 Khoá ký token nằm trong mã nguồn
Hệ thống dùng JWT để xác thực quản trị viên. Cách viết phổ biến và cũng là cách viết sai:
const JWT_SECRET = process.env.JWT_SECRET || "hieudao-admin-secret-key-2024";
Chuỗi đó nằm trong chính file mã nguồn, và file .env của máy chủ cũng đặt y hệt giá trị đó. Nghĩa là ai đọc được mã nguồn — nhân viên cũ, người nhận bàn giao, hoặc bất kỳ ai nếu kho mã nguồn từng bị lộ — đều có khoá để tự ký token quản trị.
Dấu || trong dòng lệnh trên là toàn bộ vấn đề. Nó có nghĩa: “nếu chưa cấu hình thì cứ dùng tạm giá trị này”. Với mật khẩu ký token, đó là quyết định sai. Thiếu khoá thì phải dừng khởi động, không được chạy tiếp với giá trị mặc định.
Tôi kiểm chứng bằng cách tự ký một token với vai trò owner bằng chính chuỗi trong mã nguồn rồi gọi thử. Kết quả:
| Endpoint gọi bằng token tự ký | Kết quả |
|---|---|
GET /api/admin/staff-shares |
HTTP 200 — đọc được dữ liệu cổ phần, giá trị doanh nghiệp |
GET /api/admin/chat/threads |
HTTP 200 — đọc được tin nhắn riêng giữa khách và chủ tiệm |

Đây là loại lỗi mà mức độ nghiêm trọng không nằm ở kỹ thuật mà ở hệ quả kinh doanh: tin nhắn riêng của khách hàng bị đọc được, và dữ liệu tài chính nội bộ của doanh nghiệp cũng vậy.
Cách sửa gồm hai phần, thiếu một phần là chưa xong:
const JWT_SECRET = (process.env.JWT_SECRET || "").trim();
if (!JWT_SECRET || JWT_SECRET.length < 32 || JWT_SECRET === "gia-tri-mac-dinh-cu") {
throw new Error(
"JWT_SECRET chưa được đặt hoặc quá yếu. " +
"Thêm JWT_SECRET=<chuỗi ngẫu nhiên dài> vào .env rồi khởi động lại."
);
}
Sinh khoá mới bằng công cụ có sẵn, không tự nghĩ ra chuỗi:
node -e "process.stdout.write(require('crypto').randomBytes(48).toString('base64url'))"
Lưu ý vận hành: đổi khoá ký làm mọi token quản trị cũ mất hiệu lực. Người quản trị phải đăng nhập lại. Cần thông báo trước, tránh đổi vào giờ cao điểm.
5.2 Tài khoản chưa đặt mật khẩu vào được bằng mật khẩu bất kỳ
Lỗi này nằm gọn trong một câu if:
if (user.password) {
if (!password) return res.status(400).json({ error: "Vui lòng nhập mật khẩu." });
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ error: "Mật khẩu không chính xác." });
}
// nếu tài khoản KHÔNG có mật khẩu → bỏ qua toàn bộ kiểm tra và đăng nhập thành công
Ý định ban đầu là hợp lý: tiệm tạo hồ sơ hộ khách tại quầy, khách chưa đặt mật khẩu, nên cho vào để xem lịch hẹn. Nhưng hệ quả là: chỉ cần biết số điện thoại là vào được tài khoản đó — xem ví, điểm thưởng, lịch hẹn, và đặt lịch bằng tiền trong ví.
Số điện thoại khách hàng thì không phải thông tin bí mật. Nó nằm trong sổ hẹn, trong tin nhắn, trong nhóm chat.
Cách sửa đúng không phải là “thêm mật khẩu mặc định” — mà là tách hai luồng ra. Tài khoản chưa đặt mật khẩu thì khi đăng nhập phải xác minh bằng kênh khác (mã gửi qua SMS/email), hoặc bắt buộc đặt mật khẩu trước khi xem bất kỳ dữ liệu nào. Không bao giờ được coi “chưa có mật khẩu” là “không cần mật khẩu”.
Trung thực mà nói: tại thời điểm viết bài, lỗi này CHƯA được xử lý. Nó vẫn nằm trong danh sách đỏ của bộ test, và tôi để nguyên như vậy thay vì xoá test đi cho đẹp báo cáo. Đây là ví dụ rõ nhất cho việc một bộ test chỉ có giá trị khi nó dám nói ra điều mình chưa làm được.
5.3 Đăng nhập không giới hạn số lần thử
Tôi gửi 25 lần đăng nhập sai liên tiếp. Không lần nào bị chặn. Nghĩa là việc dò mật khẩu không bị giới hạn tốc độ — với mật khẩu ngắn hoặc trùng thông tin cá nhân, đây là đường vào thực tế.
Phần này trong bộ test vẫn đang báo động, vì tôi chưa triển khai giới hạn. Ghi lại đây để không quên — và để thấy rằng một bộ test bảo mật có giá trị nhất khi nó dám giữ nguyên các mục chưa xử lý, thay vì báo xanh cho oai.
6. Nhóm 5 — Chèn SQL, gửi thư, cấu hình
Không phải mọi thứ đều tệ. Phần này có cả tin tốt và tin xấu — và tin tốt rất đáng nói vì nó chứng minh rằng dùng đúng công cụ thì an toàn theo mặc định.
6.1 Chèn SQL — an toàn, và đây là lý do
Tôi thử ba dạng payload phổ biến nhất trên các endpoint nhận tham số trực tiếp từ URL:
' OR '1'='1
'; DROP TABLE users;--
1 UNION SELECT null--
Cả ba đều không gây lỗi máy chủ và không trả về dữ liệu. Lý do: tầng truy cập dữ liệu dùng ORM có tham số hoá (Drizzle ORM với tham số $1), nên giá trị do người dùng nhập không bao giờ được ghép vào chuỗi SQL.
// An toàn: tham số hoá
await db.execute(sql`select id from users where phone = ${phone} limit 1`);
// NGUY HIỂM: ghép chuỗi — KHÔNG bao giờ làm thế này
await db.execute("select id from users where phone = '" + phone + "'");
Bài học: chèn SQL gần như biến mất nếu bạn không bao giờ ghép chuỗi để tạo câu truy vấn. Vấn đề chỉ quay lại khi có người viết tay một câu SQL động “cho nhanh”.
6.2 Endpoint gửi thư mở công khai
Dự án có một endpoint để thử gửi email trong lúc phát triển:
GET /api/debug-email?to=<địa chỉ bất kỳ>
Nó không cần đăng nhập, và nó gửi thư thật qua máy chủ của tiệm. Hậu quả: bất kỳ ai cũng dùng được tên miền của doanh nghiệp để gửi thư tới người khác. Đây là gửi thư rác bằng tài sản của bạn — và hình phạt đến không phải bằng hoá đơn, mà bằng uy tín tên miền. Khi đủ nhiều người đánh dấu thư là rác, toàn bộ email hợp lệ của tiệm (xác thực tài khoản, nhắc lịch hẹn) đều rơi vào hộp thư rác. Sửa lại được thì rất chậm.
Cách xử lý đúng: endpoint gỡ hẳn khỏi production, hoặc bắt buộc quyền quản trị. Dự án này chọn cách thứ hai.
6.3 Route tạo sẵn tài khoản quản trị
Dự án có một route “tiện lợi” dùng khi cài đặt lần đầu:
POST /api/admin/setup
→ nếu chưa có admin tên "admin", tạo mới với mật khẩu cứng "admin123"
Route này không cần đăng nhập. Hiện tại nó trả về lỗi vì đã có admin rồi — nhưng đó là an toàn do tình cờ, không phải an toàn do thiết kế. Nếu một ngày tài khoản admin bị đổi tên hoặc xoá, bất kỳ ai cũng tạo được tài khoản quản trị cao nhất với mật khẩu đã biết.
Nguyên tắc: route tạo tài khoản đặc quyền phải là script chạy tay trên máy chủ, không bao giờ là endpoint HTTP. Dự án đã xoá hẳn route này và dùng script có sẵn cho việc cài đặt.
7. Sửa xong thì đo lại thế nào
Sửa bảo mật mà không đo lại thì không khác gì không sửa — bạn chỉ đang tin vào cảm giác. Bộ test cần chạy được trước và sau, và cho ra con số so sánh được.
| Chỉ số | Trước khi sửa | Sau khi sửa |
|---|---|---|
| Tổng số phát hiện | 14 | 6 |
| — Nghiêm trọng (CRITICAL) | 6 | 1 (chưa xử lý) |
| — Cao (HIGH) | 3 | 0 |
| — Trung bình (MEDIUM) | 3 | 3 |
| — Thấp (LOW) | 2 | 2 |
| Endpoint quản trị gọi được khi chưa đăng nhập | 5+ | 0 |
| Token tự ký bằng khoá trong mã nguồn | Vào được | Bị từ chối |
| Trường nhạy cảm lộ qua API | hash mật khẩu, 2 loại token | 0 |
| Test chức năng đạt | 30/37 | 38/38 |

6 phát hiện còn lại, nói rõ từng cái để không ai tưởng đã sạch:
- 1 nghiêm trọng (chưa xử lý) — tài khoản chưa đặt mật khẩu vẫn đăng nhập được bằng mật khẩu bất kỳ (mục 5.2). Đây là việc tiếp theo, không phải việc đã xong.
- 2 trung bình — dò được số điện thoại nào đã đăng ký (qua phản hồi đăng nhập và qua endpoint tra cứu).
- 1 trung bình — đăng nhập không có giới hạn số lần thử (mục 5.3).
- 2 thấp — header
x-powered-bytiết lộ công nghệ máy chủ, và thiếu headerx-content-type-options.
Toàn bộ 6 mục này vẫn đang báo đỏ trong bộ test, và tôi cố ý giữ nguyên như vậy. Một bộ test báo đỏ trung thực có giá trị hơn một bộ test báo xanh bằng cách bỏ qua các trường hợp khó — nó biến “việc còn nợ” thành một danh sách có kiểm chứng, thay vì một cảm giác yên tâm không có cơ sở.
Ba nguyên tắc khi viết bộ test bảo mật
1. Test có thay đổi dữ liệu tuyệt đối không được chạy nhầm vào production. Dự án này có một hàm chặn cứng: nếu địa chỉ API không phải máy local thì dừng, không chạy. Không có ngoại lệ, không có cờ bỏ qua tiện lợi. Một bộ test có thể nạp tiền vào ví và xoá danh mục — chạy nhầm vào dữ liệu thật là thảm hoạ.
2. Dữ liệu test phải tạo được và dọn được. Tôi tạo khách test bằng câu SQL trực tiếp, không gọi API đăng ký — vì gọi API sẽ gửi email xác thực thật tới một địa chỉ không tồn tại, làm hỏng uy tín tên miền (đúng cái rủi ro vừa phân tích ở mục 6.2).
3. Dọn dẹp phải tự dò khoá ngoại. Lần đầu tôi viết hàm xoá khách test và bị kẹt vì khoá ngoại từ bảng giao dịch. Tên bảng tôi đoán sai — bảng thật là wallet_transactions, không phải transactions. Cách làm chắc chắn là để database tự trả lời:
select tc.table_name, kcu.column_name
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on kcu.constraint_name = tc.constraint_name
join information_schema.constraint_column_usage ccu
on ccu.constraint_name = tc.constraint_name
where tc.constraint_type = 'FOREIGN KEY'
and ccu.table_name = 'users' and ccu.column_name = 'id'
Chạy truy vấn đó rồi xoá theo đúng thứ tự — không bao giờ phải đoán tên bảng nữa.
8. Checklist 12 điểm tự kiểm tra
Dành cho hệ thống của chính bạn. Mỗi điểm có cách kiểm tra cụ thể, không cần công cụ đặc biệt.
| # | Kiểm tra gì | Cách làm |
|---|---|---|
| 1 | Bao nhiêu endpoint có kiểm tra quyền | grep -c số route và số lần gọi hàm kiểm tra quyền |
| 2 | Gọi thử 5 endpoint quản trị không kèm token | Phải trả 401/403. Nhận 200 là lỗ hổng |
| 3 | API danh sách trả về những trường gì | In ra Object.keys() của bản ghi đầu tiên, soi từng tên trường |
| 4 | Khoá ký token có nằm trong mã nguồn không | Tìm mọi chuỗi mặc định sau dấu || trong file xác thực |
| 5 | Tự ký một token bằng khoá cũ, gọi thử endpoint quản trị | Phải bị từ chối |
| 6 | Tài khoản không mật khẩu có đăng nhập được bằng mật khẩu bất kỳ không | Tạo tài khoản test không mật khẩu rồi thử đăng nhập |
| 7 | Đăng nhập sai 20–25 lần liên tiếp | Có bị chặn (429) không |
| 8 | Endpoint nào gửi được thư/nội dung ra ngoài | Tìm mọi route có gửi email/SMS, kiểm tra quyền |
| 9 | Route tạo tài khoản đặc quyền | Phải là script, không phải endpoint HTTP |
| 10 | Thử chèn SQL trên các tham số URL | Không được trả 500, không được trả 200 kèm dữ liệu |
| 11 | CORS có mở cho tên miền lạ không | Gửi request kèm Origin: https://ten-mien-la.example, xem header trả về |
| 12 | Response lỗi có lộ chi tiết nội bộ không | Tìm chuỗi at Object., node_modules, select trong body lỗi |
Câu hỏi thường gặp
Hệ thống nhỏ như tiệm nail có thật sự cần kiểm tra bảo mật không?
Càng nhỏ càng cần theo một nghĩa cụ thể: hệ thống nhỏ thường chứa dữ liệu cá nhân khách hàng và tiền thật, nhưng lại ít được rà soát nhất. Rủi ro bị nhắm mục tiêu thì thấp, nhưng rủi ro bị quét tự động thì không phụ thuộc vào quy mô — bot quét toàn bộ internet, không phân biệt tiệm lớn hay nhỏ.
Bao lâu nên chạy lại bộ test bảo mật?
Trước mỗi lần triển khai có thay đổi ở tầng route. Với dự án này, toàn bộ bộ test chức năng và bảo mật chạy dưới hai phút — rẻ hơn rất nhiều so với việc phát hiện lỗi sau khi đã lên production.
Nếu test báo lỗi mà tôi chưa có thời gian sửa thì sao?
Cứ để nó báo đỏ. Một bộ test báo đỏ trung thực có giá trị hơn một bộ test báo xanh bằng cách bỏ qua các trường hợp khó. Trong dự án này tôi cố ý giữ hai mục đỏ (giới hạn đăng nhập, header máy chủ) làm danh sách việc cần làm có kiểm chứng.
Dùng ORM có đảm bảo an toàn trước chèn SQL không?
Có, miễn là bạn không ghép chuỗi. ORM an toàn vì nó tham số hoá. An toàn đó mất ngay khi bạn chuyển sang câu SQL viết tay có nội suy giá trị người dùng. Nguyên tắc nhớ: giá trị thì truyền qua tham số, cấu trúc (tên bảng, tên cột) mới được ghép — và phải nằm trong danh sách trắng do bạn kiểm soát.
Có cần thuê kiểm thử bảo mật bên ngoài không?
Nếu hệ thống xử lý thanh toán hoặc dữ liệu y tế thì nên. Với dự án SME, bước đầu tiên và mang lại nhiều giá trị nhất là tự chạy checklist 12 điểm ở trên — nó bắt được phần lớn lỗi phổ biến với chi phí gần bằng không. Thuê ngoài nên là bước tiếp theo, để kiểm tra những thứ bạn không tự thấy được.
Kết luận
Điều đáng suy nghĩ nhất sau toàn bộ việc này không phải là 14 lỗ hổng — mà là thời gian phát hiện.
Hệ thống đã chạy ổn định, giao diện đã được thử rất nhiều vòng, và mọi tính năng đều hoạt động đúng. Không có bất kỳ dấu hiệu nào cho thấy có vấn đề. Tất cả 14 lỗ hổng đều im lặng.
- Nạp tiền trái phép? API trả về 200, trông y như thao tác hợp lệ.
- Rò rỉ hash mật khẩu? HTTP 200, kèm một khối JSON dài — nhìn không khác dữ liệu bình thường.
- Khoá ký token nằm trong mã nguồn? Không có thông báo lỗi nào. Hệ thống vẫn chạy tốt.
Bảo mật không có triệu chứng. Không có nó, hệ thống không sập, không chậm, không báo lỗi. Nó chỉ âm thầm cho phép những việc không nên được phép — cho tới khi có người phát hiện ra.
Vì vậy, bước quan trọng nhất không phải là sửa lỗi, mà là xây dựng thói quen kiểm tra định kỳ. Với dự án này, thói quen đó chỉ tốn hai phút mỗi lần triển khai. Đổi lại là sự yên tâm rằng không có cánh cửa nào bị bỏ mở.
Muốn tự tay làm được những việc này? Khóa Lập trình NodeJS — Express API tại Hướng Nghiệp Dữ Liệu dạy backend trên đúng stack đang chạy production của dự án này: REST API, xác thực, phân quyền, kết nối database, triển khai. Không phải bài tập todo-list.
Bài tiếp theo trong loạt: Xử lý lỗi trong Node.js/Express: một dòng console.error làm treo cả hệ thống · Kiểm thử API tự động: 3 lớp test cho dự án thật
Cập nhật lần cuối: 01/10/2026.
Đặng Trí Thanh
Giám đốc Công nghệ · DNT Digital · Giảng viên HNDL Hướng Nghiệp Dữ LiệuĐặng Trí Thanh — Founder & CTO · Hướng Nghiệp Dữ Liệu - DNT Digital. Chuyên đào tạo và triển khai thực chiến Python, MT5 và hệ thống bot auto trading / IB cho học viên và doanh nghiệp.