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. Bài này đ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. Không nhắm vào hệ thống của ai.

Row-Level Security: phep kiem quyen o tang du lieu, ap cho moi truy van

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ả.

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_hangkhô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.

Đ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

Sự 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: 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 thực tế

  • 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.

Chỗ tôi không kết luận được

RLS có chi phí hiệu năng: mỗi truy vấn phải đánh giá thêm điều kiện chính sách, và với chính sách phức tạp (kiểm qua nhiều bảng, hàm) chi phí đó không nhỏ. Tôi đo tính đúng, không đo chi phí — một chính sách RLS nặng trên bảng lớn dưới tải cao là một phép đo hiệu năng riêng.

Và tôi chỉ đo một chính sách đơn giản (sở hữu trực tiếp). Mô hình thật phức tạp hơn — quyền theo vai, chia sẻ, tổ chức — và RLS diễn đạt được chúng nhưng chính sách trở nên khó đọc và khó kiểm thử. Có một ngưỡng mà logic quyền phức tạp tới mức đặt trong SQL trở thành gánh nặng bảo trì; tôi không đo ngưỡng đó ở đâu.

Thử ba mươi giây

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 usesupertrue, 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 relrowsecurityfalse, 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.