Hãy so hai cách giữ bí mật trong một toà nhà văn phòng. Cách thứ nhất: dán lên tường một tấm biển "chỉ nhân viên phòng A được đọc hồ sơ này", và tin rằng mọi người sẽ đọc biển và tự giác — chỉ cần một người bỏ qua tấm biển là bí mật rò ra. Cách thứ hai: lắp một cửa từ chỉ mở cho thẻ phòng A, đặt ngay lối vào phòng lưu trữ — dù ai đó quên đọc biển, cái cửa vẫn không cho họ vào. Bài trước dùng cách thứ nhất (một câu if ở tầng ứng dụng); bài này đo cách thứ hai — Row-Level Security đặt phép kiểm ngay tại cửa của dữ liệu.
Bài trước cho thấy IDOR là một câu if bị quên ở tầng ứng dụng — và vấn đề với "một câu if" là bạn phải nhớ viết nó ở mọi endpoint, mọi truy vấn, mãi mãi. Quên một chỗ là một lỗ hổng. Tôi đo một cách tiếp cận khác: đẩy phép kiểm quyền xuống tầng cơ sở dữ liệu bằng Row-Level Security (RLS) của PostgreSQL, để phép kiểm áp dụng cho mọi câu truy vấn — kể cả câu bạn quên viết điều kiện.
Toàn bộ chạy trên PostgreSQL thật trong container, tự dọn. Tôi chỉ tấn công dữ liệu do chính mình tạo, không nhắm vào hệ thống của ai.
Row-Level Security là gì
RLS cho phép bạn gắn một chính sách vào một bảng: một điều kiện mà PostgreSQL tự động thêm vào mọi truy vấn chạm tới bảng đó. Ở đây, chính sách là: chỉ thấy các dòng có chu = người dùng hiện tại, với "người dùng hiện tại" lấy từ một biến phiên mà ứng dụng đặt sau khi xác thực. Sau khi bật, cơ sở dữ liệu tự lọc — lập trình viên không phải viết WHERE chu = ... ở đâu cả. Đó là cái cửa từ ở lối vào.
Số đo
Ba lần chạy, kết quả giống hệt:
Cùng một câu SQL "lấy hết", kết quả khác nhau theo người dùng. Câu truy vấn là SELECT id, noi_dung FROM don_hang — không có điều kiện lọc nào, đúng kiểu code quên kiểm quyền:
| Người dùng hiện tại | Số dòng thấy được |
|---|---|
an |
2 (đúng hai đơn của An) |
binh |
1 (đúng đơn của Binh) |
IDOR ở tầng dữ liệu bị chặn:
| Truy vấn | Kết quả |
|---|---|
An: SELECT ... WHERE id = 2 (đơn của Binh) |
rỗng — RLS chặn |
Binh: SELECT ... WHERE id = 2 (đơn của mình) |
thấy |
An: UPDATE ... WHERE id = 2 (đơn của Binh) |
0 dòng bị sửa |
Chính sách trông thế nào
Toàn bộ cơ chế gói trong ba câu lệnh, và đáng nhìn kỹ vì nó ngắn tới bất ngờ:
ALTER TABLE don_hang ENABLE ROW LEVEL SECURITY;
CREATE POLICY chi_don_cua_minh ON don_hang
USING (chu = current_setting('app.nguoi_dung', true));
Câu đầu bật RLS cho bảng. Câu sau định nghĩa chính sách: điều kiện trong USING là bộ lọc PostgreSQL tự thêm vào mọi truy vấn. current_setting('app.nguoi_dung') đọc biến phiên mà ứng dụng đặt bằng SET app.nguoi_dung = 'an' ngay sau khi xác thực. Từ đó, cơ sở dữ liệu tự biến SELECT * FROM don_hang thành SELECT * FROM don_hang WHERE chu = 'an' — nhưng ở tầng mà không câu SQL nào của lập trình viên can thiệp được.
So với bài IDOR: ở đó phép kiểm là code Python chạy trước truy vấn, và có thể bị bỏ qua. Ở đây phép kiểm là một phần của chính truy vấn, do cơ sở dữ liệu chèn vào, không thể bỏ qua từ phía ứng dụng.
Ba điều đáng nhớ
RLS lọc ngay cả câu SQL không có điều kiện lọc. Đây là điểm mạnh cốt lõi và là lý do bài này tồn tại. Ở bài IDOR, lỗ hổng là lập trình viên quên viết if chủ == người_đăng_nhập. Với RLS, dù lập trình viên viết một câu SELECT * FROM don_hang trần trụi — không kiểm gì — cơ sở dữ liệu vẫn chỉ trả về dòng người dùng được phép xem. Phép kiểm không còn phụ thuộc việc lập trình viên nhớ; nó là thuộc tính của chính bảng. Một endpoint mới, một truy vấn báo cáo, một script chạy tay — tất cả tự động chịu cùng chính sách.
RLS áp cho mọi động từ, không chỉ SELECT. Bài IDOR nhấn mạnh phải kiểm quyền ở cả xem, sửa, xoá. RLS làm việc đó một lần: An thử UPDATE đơn của Binh, kết quả là 0 dòng bị sửa — cơ sở dữ liệu coi như đơn đó không tồn tại với An. Không có cách nào viết một câu UPDATE hay DELETE "quên kiểm quyền" khi RLS bật, vì chính sách gắn với bảng chứ không với câu lệnh.
Đây là "phòng thủ theo chiều sâu" đúng nghĩa, không phải thay thế. RLS không thay cho kiểm quyền ở tầng ứng dụng — nó là lớp thứ hai, đặt ở nơi khó quên nhất: chính cơ sở dữ liệu. Nếu tầng ứng dụng có một lỗ IDOR (quên một câu if), RLS vẫn chặn. Nếu ai đó chạy một truy vấn trực tiếp bỏ qua ứng dụng, RLS vẫn chặn. Hai lớp ở hai tầng khác nhau, và kẻ tấn công phải vượt cả hai.
Vì sao khác biệt nằm ở nơi đặt sự thật về quyền
- Kiểm ở tầng ứng dụng: quyền là logic rải khắp các handler. Đúng khi mọi handler đều nhớ kiểm; sai ngay khi một handler quên. Số điểm cần đúng bằng số endpoint — và tăng theo thời gian.
- Kiểm ở tầng dữ liệu (RLS): quyền là một chính sách gắn với bảng. Số điểm cần đúng bằng một — chính sách đó. Mọi truy vấn, dù từ đâu, đều đi qua nó.
Điều này lật ngược gánh nặng: thay vì "nhớ kiểm quyền ở mọi nơi", thành "định nghĩa quyền một nơi, và không thể bỏ qua". Với hệ thống lớn, nhiều lập trình viên, nhiều endpoint, đây là khác biệt giữa "có thể có IDOR ở đâu đó" và "IDOR ở tầng này là bất khả về mặt kiến trúc".
Có một điều kiện tối quan trọng, và là chỗ tôi phải sửa giữa chừng: kết nối phải bằng một role không phải superuser. Superuser (và role có thuộc tính BYPASSRLS) bỏ qua mọi chính sách RLS. Nếu ứng dụng của bạn kết nối cơ sở dữ liệu bằng tài khoản superuser — điều đáng tiếc là rất phổ biến — thì RLS bạn viết ra không có tác dụng gì. Tôi phải tạo một role ung_dung riêng, cấp quyền vừa đủ, để RLS thực sự có hiệu lực.
Nghĩa là gì trong công việc hằng ngày
- Dùng RLS như lớp phòng thủ thứ hai cho dữ liệu nhạy cảm phân theo người dùng — đơn hàng, tin nhắn, hồ sơ. Nó bắt được đúng loại lỗi (quên kiểm quyền) mà tầng ứng dụng hay mắc.
- Kết nối bằng role hạn chế, không phải superuser. Đây là điều kiện để RLS có tác dụng, và cũng là nguyên tắc đặc quyền tối thiểu tốt.
- Đặt danh tính qua biến phiên đã xác thực, như
SET app.nguoi_dung. Giá trị này phải đến từ phiên server tin cậy, không từ dữ liệu client — giống hệt bài IDOR. - Đừng bỏ kiểm quyền tầng ứng dụng vì đã có RLS. RLS là lưới thứ hai; kiểm tầng ứng dụng cho thông báo lỗi tốt hơn (403 rõ ràng thay vì "kết quả rỗng khó hiểu") và bắt lỗi sớm hơn trong luồng.
Cùng một ý tưởng "hạ phép kiểm xuống tầng khó quên nhất", đổi ngăn xếp
RLS là hiện thân đẹp của một nguyên tắc rộng hơn: đặt phép kiểm quyền ở tầng thấp nhất có thể, nơi mọi đường đi tới dữ liệu đều phải qua — và các hệ khác đều có phiên bản của nó.
Trong thế giới Java, muốn dùng RLS của PostgreSQL, mấu chốt là đặt biến phiên đúng cho đúng connection trước mỗi request — thường qua một TransactionSynchronization hoặc AOP interceptor chạy SET app.nguoi_dung = ? khi mượn connection từ pool. Cái bẫy đặc thù Java: HikariCP tái dùng connection, nên nếu bạn SET mà quên RESET khi trả về pool, request sau thừa hưởng danh tính của request trước — một lỗ nghiêm trọng. Ở tầng thuần Java, tương đương RLS là Spring Security method-level @PostFilter/ACL, nhưng nó vẫn ở tầng ứng dụng nên vẫn "quên được"; RLS mạnh hơn vì nằm dưới cả JPA.
Trong Go, cùng mẫu: bạn chạy SET LOCAL app.nguoi_dung = $1 trong cùng một transaction với truy vấn (dùng SET LOCAL để tự reset khi transaction kết thúc, tránh đúng cái bẫy pool ở trên). Vì database/sql quản pool tường minh, lập trình viên Go phải chủ động bọc mỗi request trong một transaction có set biến — trần trụi hơn nhưng an toàn hơn về vòng đời.
Còn ở chính cơ sở dữ liệu — đây là sân nhà — RLS là công cụ multi-tenancy hạng nhất: nhiều SaaS dùng một chính sách tenant_id = current_setting(...) để cô lập dữ liệu khách hàng bằng một chính sách thay vì WHERE tenant_id = ? rải khắp hàng nghìn truy vấn. Cùng ý tưởng, chỉ đổi cột lọc từ người-dùng sang khách-hàng.
Mẫu số chung: phép kiểm quyền càng đặt gần dữ liệu và càng ít điểm cần đúng thì càng khó bị quên — và "một chính sách gắn với bảng" đánh bại "một câu if ở mỗi endpoint" đúng vì số điểm phải đúng giảm từ N (số endpoint) xuống 1. Kiến trúc an toàn không đến từ việc nhớ kỹ hơn, mà từ việc dời phép kiểm tới nơi không thể đi vòng qua. Nên câu hỏi đúng khi thiết kế phân quyền không phải "tôi đã kiểm ở mọi handler chưa" mà là "có tầng nào bên dưới mọi handler mà tôi đặt phép kiểm ở đó một lần cho tất cả không".
Muốn tự soát, nếu bạn dùng PostgreSQL, kiểm hai điều. Thứ nhất: ứng dụng của bạn kết nối bằng role gì? Chạy SELECT current_user, usesuper FROM pg_user WHERE usename = current_user. Nếu usesuper là true, mọi RLS bạn có thể viết sẽ bị bỏ qua. Thứ hai: các bảng chứa dữ liệu riêng của người dùng có bật RLS không? SELECT relname, relrowsecurity FROM pg_class WHERE relname = 'ten_bang'. Nếu relrowsecurity là false, toàn bộ việc phân tách dữ liệu đang dựa hoàn toàn vào việc tầng ứng dụng không bao giờ quên một câu if — chính là điều bài IDOR cho thấy là mong manh.