AI 编程工具修复一处 IDOR 后,同文件其他路由(PATCH/DELETE/GET list)仍存在相同漏洞,呈系统性模式。
Bạn nhờ Cursor sửa một lỗi IDOR (insecure direct object reference) trên route GET /api/invoices/:id, và nó sửa đúng ngay lập tức — thêm điều kiện kiểm tra chủ sở hữu vào query. Nhưng cuộn xuống 20 dòng, route DELETE trên cùng resource vẫn gọi findByIdAndDelete(req.params.id) không kiểm tra gì, còn route GET /api/invoices (list) thì trả về toàn bộ dữ liệu của mọi user. Đây không phải lỗi hiếm — đó là pattern lặp lại mỗi khi AI coding agent sửa IDOR, và bài này chỉ ra vì sao, kèm cách chặn triệt để.
Theo một kỹ sư chia sẻ chi tiết trên Dev.to, miếng vá Cursor viết cho route GET là chính xác: điều kiện sở hữu được đưa vào query (findOne({_id, userId: req.user.id})) thay vì kiểm tra sau khi lấy dữ liệu, và khi không tìm thấy thì trả 404 thay vì 403 — cách làm đúng vì không để lộ rằng invoice của người khác tồn tại. Nếu chỉ chấm điểm riêng route này, nó pass tuyệt đối.
Vấn đề là ba route còn lại trên cùng file — PATCH, DELETE, và GET (list) — vẫn dùng primary-key lookup thuần túy, không hề biết đến điều kiện sở hữu vừa được thêm vào route kia.
IDOR trên route ghi (PATCH/DELETE) thường đòi hỏi kẻ tấn công phải đoán được ID hợp lệ trước — chi phí này phụ thuộc vào bạn dùng ID tuần tự hay UUID ngẫu nhiên. Nhưng khi route list không lọc theo chủ sở hữu, nó tự trả về toàn bộ ID hợp lệ, biến bước "dò ID" thành một request đơn giản, được xác thực và trông giống hệt một lượt đọc bình thường trong log truy cập — không có gì bất thường để phát hiện.
Bài viết dẫn CVE-2026-47418 (CVSS 8.1, tháng 6/2026) trên praisonai-platform làm ví dụ: các route project đã có kiểm tra require_workspace_member(workspace_id), nhưng sau đó lại resolve object qua ProjectService.get(project_id) — một primary-key lookup không hề có điều kiện workspace. Update và delete gọi lại đúng hàm get() đó nên thừa hưởng luôn lỗ hổng. Advisory ghi nhận cùng một lỗi cấu trúc lặp lại ba lần trong cùng codebase — trên project, issue (CVE-2026-47415) và agent (CVE-2026-47419) — bởi một team rõ ràng đã hiểu khái niệm membership check, vì họ từng viết một cái.
Với Django REST Framework, tài liệu chính thức nêu rõ: has_object_permission() chỉ chạy khi get_object() được gọi — tức bao phủ retrieve/update/destroy nhưng KHÔNG bao phủ list và create. Ngược lại get_queryset() bao phủ list/retrieve/update/destroy nhưng cũng không chạm vào create. Khi bạn dán một detail view và nói "fix IDOR", AI agent gần như luôn chọn viết permission class (cơ chế nghe "bảo mật" hơn) — đúng cách nhưng vẫn để lộ route list.
Cách xử lý bền vững không nằm ở dòng code sửa lỗi, mà ở việc không còn chỗ để quên nó. Với DRF, scope ngay trong get_queryset() (vì đây là cơ chế duy nhất phủ được list) và xử lý create riêng bằng perform_create() để gán owner từ session, không bao giờ từ request body. Với Express/Mongoose, dùng một helper scope chung như owned(req) => ({ userId: req.user.id }) và bắt mọi handler — get, list, patch, delete — đều phải spread qua nó. Đây là ví dụ minh họa pattern, cần điều chỉnh theo schema thực tế của bạn.
// Express/Mongoose — shared ownership scope helper
const owned = (req) => ({ userId: req.user.id });
// Mọi handler đều phải đi qua owned()
router.get('/invoices', async (req, res) => {
const invoices = await Invoice.find(owned(req));
res.json(invoices);
});
router.get('/invoices/:id', async (req, res) => {
const invoice = await Invoice.findOne({ _id: req.params.id, ...owned(req) });
if (!invoice) return res.status(404).end();
res.json(invoice);
});
router.patch('/invoices/:id', async (req, res) => {
const invoice = await Invoice.findOneAndUpdate(
{ _id: req.params.id, ...owned(req) },
req.body,
{ new: true }
);
if (!invoice) return res.status(404).end();
res.json(invoice);
});
router.delete('/invoices/:id', async (req, res) => {
const invoice = await Invoice.findOneAndDelete({ _id: req.params.id, ...owned(req) });
if (!invoice) return res.status(404).end();
res.status(204).end();
});
# Django REST Framework — scoped get_queryset
class InvoiceViewSet(ModelViewSet):
def get_queryset(self):
# Scope được apply cho cả list lẫn retrieve/update/destroy
return Invoice.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
# Gán owner từ session, không bao giờ từ request body
serializer.save(owner=self.request.user)
Nếu tuần trước bạn từng nhờ AI coding agent sửa một IDOR, hãy mở lại đúng file đó: kiểm tra route list và các route ghi (PATCH/DELETE) trên cùng resource có đi qua cùng điều kiện sở hữu với route vừa sửa hay không. Đừng tin vào commit message "fixed IDOR" — nó chỉ đúng cho đúng một route mà bạn đã dán vào prompt.
This article was originally published on NextFuture. Follow us for more fullstack & AI engineering content.