Là gì
Access control là phần trả lời câu hỏi "người dùng NÀY được làm gì với đối tượng NÀY". Lỗi phân quyền xảy ra khi hệ thống đã biết bạn là ai (xác thực xong) nhưng không kiểm bạn có được phép hay không. IDOR/BOLA là dạng phổ biến nhất: đổi id=42 thành id=43 và đọc được dữ liệu của người khác.
Vì sao bạn quan tâm
A01 đứng đầu OWASP Top 10 hai bản liên tiếp, và lý do không phải vì nó khó hiểu — mà vì nó là lỗi không có chữ ký cú pháp. SQL injection có dấu nháy để grep; phân quyền thiếu trông y hệt phân quyền có: cả hai đều là một câu FindByIdAsync(id). Không có linter nào tìm được nó, không có WAF nào chặn được nó, và scanner tự động thì mù vì nó cần hiểu ai nên đọc được cái gì — nghĩa là nó cần hiểu nghiệp vụ của bạn.
Về phía developer, ba đặc điểm khiến nó lặp lại mãi:
- Nó nằm ở mọi endpoint. Một endpoint mới là một cơ hội mới để quên.
- Test happy-path luôn xanh. Alice đọc đơn hàng của Alice — pass. Không ai viết test "Alice đọc đơn của Bob" trừ khi có quy ước bắt viết.
- Nó thoái hoá âm thầm. Endpoint có kiểm quyền hôm nay, rồi tháng sau có người thêm một overload "cho job nội bộ dùng" và bỏ tham số
userId.
Về thiệt hại: không cần leo thang, không cần payload. Một vòng for trên id là toàn bộ cơ sở dữ liệu, và trong log nó trông như lưu lượng bình thường của một người dùng đã đăng nhập.
Cơ chế hoạt động
Cơ chế là một câu hỏi bị bỏ sót. Hệ thống hỏi "anh là ai?" rồi đi thẳng tới truy vấn, không hỏi "cái này có phải của anh?".
sequenceDiagram autonumber actor B as Bob (đã đăng nhập) participant API participant DB B->>API: GET /api/orders/1042<br/>Authorization: Bearer <token của Bob> Note over API: ✅ Token hợp lệ → Bob là Bob.<br/>❌ Không ai hỏi 1042 có phải của Bob. API->>DB: SELECT * FROM orders WHERE id = 1042 DB-->>API: đơn hàng của Alice API-->>B: 200 — địa chỉ, 4 số thẻ cuối, lịch sử mua của AliceĐiểm quan trọng về mặt kỹ thuật: câu truy vấn là chỗ lỗi nằm, không phải middleware. WHERE id = 1042 không có chỗ nào để một middleware chen vào. Middleware biết Bob là Bob; nó không biết 1042 là gì. Đó là lý do bản vá đúng phải nằm trong câu truy vấn — xem khối 6.
Bốn dạng, và chúng cần bốn bản vá khác nhau:
| Dạng | Ví dụ | Bản vá |
|---|---|---|
| BOLA / IDOR (API1) | GET /orders/1042 của người khác | Ownership trong WHERE |
| BFLA (API5) | DELETE /admin/users/7 từ tài khoản thường | Kiểm quyền theo hàm, mặc định là từ chối |
| Mass assignment (API3) | PATCH {"role":"admin"} | DTO riêng cho input, không bind vào entity |
| Leo thang ngang qua tenant | ?tenantId= của tenant khác | Tenant lấy từ token, không từ request |
Dạng thứ hai đáng nói riêng: nó thường xuất hiện dưới hình thức endpoint không có trong tài liệu. UI không hiện nút Delete cho người dùng thường, nên không ai kiểm endpoint DELETE — nhưng endpoint vẫn ở đó, và Swagger nói cho ai muốn biết.
Mô tả sơ đồ: Sơ đồ tuần tự: Bob đã đăng nhập gửi GET /api/orders/1042 kèm token hợp lệ của mình. API xác nhận token đúng nên biết Bob là Bob, nhưng không kiểm đơn 1042 có thuộc về Bob hay không, và truy vấn DB chỉ theo id. DB trả về đơn hàng của Alice và API trả nó cho Bob, gồm địa chỉ, bốn số thẻ cuối và lịch sử mua hàng.
Ví dụ cụ thể
Cùng một endpoint, ba request. Cái thứ hai là lỗi, cái thứ ba là lý do phải đọc kỹ mã trạng thái.
# 1 — Bob đọc đơn của chính mình. Đúng như thiết kế.GET /api/orders/1041 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 200 OK{"id":1041,"total":"429000","address":"12 Nguyễn Huệ, Q1"}# 2 — Bob đọc đơn của Alice. Cùng token, cùng endpoint, chỉ khác một chữ số.GET /api/orders/1042 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 200 OK ← đây là lỗi{"id":1042,"total":"1250000","address":"88 Lê Lợi, Q3","cardLast4":"4242"}# 3 — Sau khi vá. 404, KHÔNG phải 403.GET /api/orders/1042 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 404 Not Found{"errorCode":"ER_ORDER_NOT_FOUND"}Vì sao 404 chứ không 403: 403 xác nhận đơn 1042 tồn tại. Với một vòng lặp trên id, sự khác nhau giữa 403 và 404 là một bản đồ đầy đủ về đơn hàng nào có thật — đủ để biết quy mô doanh nghiệp, tốc độ tăng trưởng, và ai là khách hàng lớn.
[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){ // [Authorize] toàn cục đã chạy: chúng ta BIẾT đây là ai. var me = _currentUser.UserId; // ❌ Truy vấn theo id, không theo (id, chủ sở hữu). Middleware nào cũng không // chen được vào đây, vì nó không biết 1042 là của ai cho tới khi đã đọc. var order = await _db.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id, ct); // ❌ Câu kiểm này ĐÚNG về hành vi và SAI về vị trí. Xoá dòng này ra thì mọi // test happy-path vẫn xanh — nên nó là dòng bị xoá trong lần refactor sau, // hoặc bị bỏ qua trong overload thứ hai mà ai đó thêm cho job nội bộ. if (order is null || order.UserId != me) return NotFound(); return Ok(OrderMapper.ToDetail(order));}Chuyện đã xảy ra
First American Financial, tháng 5/2019 — 885 triệu tài liệu. Link tới tài liệu giao dịch bất động sản có dạng …/DocumentHandler?id=<số tăng dần>, và không có kiểm quyền nào. Bất kỳ ai có một link hợp lệ đều đọc được mọi link khác bằng cách trừ đi 1. Hồ sơ gồm số tài khoản ngân hàng, ảnh bản mã số thuế, và bằng lái. Đây là IDOR ở dạng thuần khiết nhất — không có kỹ thuật nào, chỉ có một vòng lặp.
Optus, tháng 9/2022 — ~9,8 triệu khách hàng Úc. Một API không xác thực trên một subdomain, với contactId tăng dần. Báo cáo của ACMA nêu rằng endpoint đó bị bỏ quên sau một lần thay đổi hạ tầng. Lặp lại đúng bài học của khối 2: endpoint bạn quên là endpoint không ai kiểm quyền.
Peloton, 2021. API trả về dữ liệu profile (tuổi, cân nặng, thành phố, lịch tập) cho bất kỳ userId nào, kể cả với profile đã đặt riêng tư — bao gồm profile của một tổng thống Mỹ đương nhiệm. Đáng chú ý vì bản vá đầu tiên chỉ yêu cầu đăng nhập, và một tài khoản miễn phí bất kỳ vẫn đọc được tất cả.
Cách phòng chống
Ownership trong câu truy vấn, không trong câu `if`
bắt buộcĐây là biện pháp quan trọng nhất của cả trang, và lý do nằm ở chỗ nó không thể quên được: một câu SELECT … WHERE id = @id AND user_id = @me mà thiếu điều kiện thứ hai thì nó trả về sai dữ liệu trong test đầu tiên. Ngược lại, một câu if (order.UserId != me) throw; đặt SAU truy vấn thì xoá đi vẫn chạy đúng ở happy path — và đó là kiểu code bị xoá trong lần refactor.
Cụ thể: repository không được có method GetById(id) nào trả về entity thuộc phạm vi người dùng. Chỉ có GetById(id, userId). Ký hiệu hàm là nơi luật này sống, để trình biên dịch nhắc thay cho code review.
// ── Repository ───────────────────────────────────────────────────────────────public interface IOrderRepository{ /// <summary> /// KHÔNG có overload GetByIdAsync(long) nào. Đây là điểm quan trọng nhất của /// cả bản vá, và nó là một quyết định về KÝ HIỆU HÀM, không về nội dung hàm: /// khi phương án "truy vấn không có chủ sở hữu" không tồn tại trong API của /// repository thì không ai gọi nó được, kể cả người viết endpoint tháng sau /// không đọc trang này. /// /// Trình biên dịch làm việc của code review, và nó không bao giờ mệt. /// </summary> Task<Order?> GetByIdAsync(long id, UserId owner, CancellationToken ct);} public sealed class OrderRepository : IOrderRepository{ private readonly SecLabDbContext _db; public OrderRepository(SecLabDbContext db) => _db = db; public Task<Order?> GetByIdAsync(long id, UserId owner, CancellationToken ct) => _db.Orders .Include(o => o.Items) // Ownership Ở TRONG câu truy vấn. Bỏ điều kiện thứ hai ra thì test // cross-tenant đỏ NGAY, không phải đỏ ở một hệ quả xa xôi nào. .FirstOrDefaultAsync(o => o.Id == id && o.UserId == owner.Value, ct);} // ── Endpoint ─────────────────────────────────────────────────────────────────[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){ var order = await _orders.GetByIdAsync(id, _currentUser.UserId, ct); // 404, KHÔNG 403. 403 xác nhận đơn 1042 tồn tại, và với một vòng for thì // sự khác nhau giữa 403 và 404 chính là một bản đồ đầy đủ về đơn hàng nào có // thật — đủ để suy ra quy mô doanh nghiệp và ai là khách hàng lớn. if (order is null) throw new NotFoundException(CatalogErrorsList.ORDER_NOT_FOUND); return Ok(OrderMapper.ToDetail(order));} // ── Mặc định TỪ CHỐI, đặt một lần ở Program.cs ───────────────────────────────// Chiều này quan trọng: [Authorize] là mặc định, [AllowAnonymous] là ngoại lệ// phải gõ ra. Chiều ngược lại nghĩa là controller mới thêm vào sẽ mở toang, và// không ai biết cho tới khi có người tìm thấy nó.builder.Services.AddAuthorizationBuilder() .SetFallbackPolicy(new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build());Một điểm quyết định phân quyền duy nhất
Với luật phức tạp hơn ownership (chia sẻ, vai trong tổ chức, uỷ quyền), đừng rải if khắp handler. Một service IAuthorizationService.Authorize(user, action, resource) — một chỗ để đọc, một chỗ để test, một chỗ để audit. ASP.NET Core: policy-based authorization với IAuthorizationHandler.
Mặc định là TỪ CHỐI
Trong ASP.NET Core: [Authorize] ở mức controller/global filter, và [AllowAnonymous] là ngoại lệ phải viết ra. Ngược lại (mặc định mở, [Authorize] là ngoại lệ) thì một controller mới sẽ mặc định không có quyền hạn nào — và không ai nhận ra tới khi có người tìm thấy nó.
Row-Level Security ở DB
Lớp này bắt đúng cái mà lớp 1 sẽ bỏ sót: một câu truy vấn mới do người khác viết. Postgres RLS với current_setting('app.user_id') nghĩa là kể cả câu truy vấn viết sai cũng không trả về hàng của người khác. Nó không thay lớp 1 (thông báo lỗi kém, khó debug) — nó là lưới an toàn.
# Postgres RLS. Đây là lưới an toàn, KHÔNG phải bản vá chính: thông báo lỗi của nó# kém (0 hàng, không nói vì sao) và nó làm việc debug khó hơn. Giá trị của nó nằm ở# chỗ khác — nó bảo vệ câu truy vấn mà bạn CHƯA viết.migration: | ALTER TABLE core."order" ENABLE ROW LEVEL SECURITY; -- FORCE để chính chủ bảng cũng phải chịu policy. Không có FORCE thì role sở hữu -- bảng đi qua policy như không có gì, và trên nhiều hệ thống đó chính là role -- mà app đang dùng — nên thiếu dòng này là RLS bật mà không chặn ai. ALTER TABLE core."order" FORCE ROW LEVEL SECURITY; CREATE POLICY order_owner_only ON core."order" USING (user_id = current_setting('app.user_id', true)::uuid) WITH CHECK (user_id = current_setting('app.user_id', true)::uuid); connection_setup: | -- Đặt trên MỖI connection lấy từ pool, trong một interceptor của EF Core. -- SET LOCAL để giá trị chết theo transaction: nếu nó sống qua khỏi transaction -- thì connection tiếp theo lấy từ pool thừa hưởng danh tính của request trước — -- một lỗi phân quyền tệ hơn cả lỗi mà RLS đang định vá. SET LOCAL app.user_id = $1; verify: | SET app.user_id = '00000000-0000-0000-0000-000000000001'; SELECT count(*) FROM core."order" WHERE id = 1042; -- phải là 0 SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'order'; -- phải là t, tID không đoán được, và log
UUIDv7 thay số tăng dần: không phải phòng chống (ID lộ ra ở mọi nơi) nhưng nó xoá bỏ khả năng liệt kê hàng loạt, biến "một vòng for" thành "cần biết trước từng id". Cộng với alert khi một tài khoản nhận nhiều 404 trên cùng một route — đó là chữ ký của một vòng lặp đang chạy.
Kiểm chứng đã vá
Chỉ có một phép kiểm thực sự quan trọng ở topic này, và nó là phép kiểm mà hầu như không dự án nào có: mọi endpoint có tham số id phải có một test cross-tenant.
1. Test cross-tenant, theo quy ước bắt buộc. Xem tab csharp / test. Cấu trúc: hai user trong fixture, và test khẳng định user A không đọc được của user B, với mã trạng thái đúng là 404. Điểm mấu chốt là biến nó thành quy ước có thể đếm được — xem phép kiểm 3.
2. Kiểm ký hiệu repository, chặn merge:
# Repository của entity thuộc phạm vi người dùng không được có GetById một tham số.grep -rnE 'Task<[A-Za-z]+\??> GetByIdAsync\(([A-Za-z]+Id )?id' \ --include='*Repository.cs' src/ | grep -vE 'userId|tenantId|ownerId' \ && { echo "GetByIdAsync không có scope người dùng — chặn"; exit 1; }exit 03. Đếm độ phủ authz, không đếm độ phủ dòng. Liệt kê mọi route có {id} từ Swagger/EndpointDataSource, đối chiếu với danh sách test có tên khớp *_returns_404_for_other_user. Route nào không có test tương ứng thì fail build. Đây là phép kiểm biến "nhớ viết test" thành "không viết thì không merge được":
dotnet run --project tools/AuthzCoverage -- --fail-on-missing4. Kiểm mặc định-từ-chối còn nguyên:
# Mọi controller phải nằm dưới [Authorize] toàn cục; [AllowAnonymous] phải được liệt kê và duyệt.grep -rn "AllowAnonymous" --include='*.cs' src/ | diff - docs/allowed-anonymous.txt \ || { echo "Có endpoint anonymous mới chưa được duyệt"; exit 1; }5. Kiểm RLS thật sự đang chặn (lớp 2 dễ bị tưởng là đang bật mà thực ra không):
SET app.user_id = '00000000-0000-0000-0000-000000000001';-- Phải trả 0 hàng dù đơn 1042 có thật:SELECT count(*) FROM core."order" WHERE id = 1042;-- Và kiểm policy chưa bị ai tắt:SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'order';/// <summary>/// Quy ước: mỗi route có {id} phải có đúng một test tên/// <c>{Route}_returns_404_for_other_user</c>. Tool tools/AuthzCoverage liệt kê/// route từ EndpointDataSource, đối chiếu với tên test, và fail build nếu thiếu.////// Quy ước đặt tên nghe hình thức, nhưng nó là thứ biến "nhớ viết test authz"/// thành "không viết thì không merge được" — và đó là khác biệt duy nhất giữa/// một dự án có phủ authz và một dự án tin là mình có./// </summary>public class OrderAuthorizationTests : IClassFixture<TwoUserFixture>{ private readonly TwoUserFixture _fx; public OrderAuthorizationTests(TwoUserFixture fx) => _fx = fx; [Fact] public async Task GetOrder_returns_404_for_other_user() { // Alice có một đơn thật. Bob là một tài khoản hợp lệ, đăng nhập bình thường. var aliceOrder = await _fx.SeedOrderFor(_fx.Alice, total: 1_250_000); var response = await _fx.ClientAs(_fx.Bob) .GetAsync($"/api/orders/{aliceOrder.Id}"); // 404 chứ không 403: mã trạng thái là một phần của bản vá, không phải chi tiết. Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); // Và không có mảnh dữ liệu nào của Alice rò ra qua thân response. var body = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain("1250000", body); Assert.DoesNotContain(_fx.Alice.Email, body); } [Fact] public async Task GetOrder_returns_200_for_owner() { // Cặp đôi của test trên. Không có nó thì một bản vá "luôn trả 404" // cũng làm test cross-tenant xanh. var order = await _fx.SeedOrderFor(_fx.Bob, total: 429_000); var response = await _fx.ClientAs(_fx.Bob).GetAsync($"/api/orders/{order.Id}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } /// <summary> /// BFLA (API5): route admin không có trong UI của người dùng thường, nên nó là /// route không ai kiểm — và Swagger nói cho bất kỳ ai muốn biết là nó ở đó. /// </summary> [Fact] public async Task DeleteUser_returns_403_for_non_admin() { var response = await _fx.ClientAs(_fx.Bob) .DeleteAsync($"/api/admin/users/{_fx.Alice.Id}"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.NotNull(await _fx.FindUser(_fx.Alice.Id)); // vẫn còn đó }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Kiểm quyền trong middleware | Middleware biết bạn là ai, không biết 1042 là gì. Nó không thể kiểm ownership của một tài nguyên mà nó chưa đọc |
if (order.UserId != me) throw; sau truy vấn | Đúng về hành vi, sai về vị trí: xoá dòng đó ra thì happy path vẫn xanh. Bản vá nào mà xoá đi test vẫn pass là bản vá sẽ bị xoá |
| Ẩn nút trên UI | Endpoint vẫn nhận request. Đây không phải bản vá, đây là một thay đổi CSS |
| Đổi sang UUID | Làm liệt kê hàng loạt khó hơn nhiều, nhưng ID vẫn lộ qua email, log, URL chia sẻ. Đây là lớp 3, không phải lớp 1 |
| Trả 403 khi không phải của mình | Rò sự tồn tại. Một vòng lặp phân biệt 403/404 là một bản đồ tài nguyên |
| Chỉ yêu cầu đăng nhập | Đúng lỗi mà bản vá đầu của Peloton mắc: mọi tài khoản miễn phí vẫn đọc được tất cả |
| Kiểm ở tầng GraphQL resolver gốc | Field resolver lồng bên trong bỏ qua nó. Mỗi node trả về dữ liệu thuộc người dùng cần kiểm riêng |
Sai lầm về quy trình, quan trọng hơn mọi dòng trên: coi phân quyền là việc của code review. Code review tìm được lỗi có mặt trong diff; phân quyền thiếu là lỗi vắng mặt trong diff. Không thấy điều gì không có ở đó là điều con người làm rất tệ — nên nó phải là việc của ký hiệu hàm và của CI.
Sai lầm về phạm vi: vá đúng endpoint được báo cáo. Một IDOR trên /orders/{id} gần như luôn có anh em ở /invoices/{id} và /orders/{id}/items — cùng một người viết, cùng một thói quen.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…