Đang tải…
Đang tải…
Đặt lệnh kiểm tra quyền trong layout của dashboard là điều rất tự nhiên. Next không render lại layout khi điều hướng phía client, và một request RSC có thể nhắm thẳng vào page segment.

Dashboard của site này có một layout bọc mọi trang quản trị. Đặt lệnh kiểm tra quyền vào đó là chuyện hiển nhiên: viết một lần, mọi trang con được bảo vệ.
Trừ chuyện nó không bảo vệ được.
Thứ nhất, Next không render lại layout khi người dùng điều hướng phía client. Đó chính là điểm mạnh của layout: phần khung giữ nguyên, chỉ phần nội dung đổi. Một lệnh kiểm tra chạy ở đó chỉ chạy đúng lần tải trang đầu tiên.
Thứ hai, và nghiêm trọng hơn: một request RSC có thể nhắm thẳng vào một page segment. Người tấn công không phải đi qua giao diện của bạn. Họ gửi thẳng request tới đúng segment cần, và cái layout kia nằm ngoài đường đi đó.
Nên mỗi trang dashboard tự kiểm tra lại:
export default async function EditPostPage({ params }) {
await requireAdminPage();
const { id } = await params;
// …
}
Chi phí gần bằng không, vì phiên đăng nhập đã nằm sẵn trong cookie. Đổi lại, bảo đảm nằm ngay trong file thực sự đọc dữ liệu, chứ không nằm ở một file cha mà bạn phải nhớ là nó tồn tại.
export async function requireAdminPage() {
let allowed = true;
try {
await verifyAdmin();
} catch {
allowed = false;
}
// Nằm NGOÀI catch: redirect() báo hiệu bằng cách ném exception.
if (!allowed) redirect("/login?error=forbidden");
}
Dòng comment kia là phần đắt nhất của hàm này. redirect() của Next hoạt động bằng cách ném ra một exception đặc biệt mà framework bắt ở tầng trên. Viết nó bên trong khối catch thì khối catch đó nuốt luôn tín hiệu chuyển hướng, và hàm trả về bình thường.
Kết quả: mọi lần đáng lẽ phải chặn lại thành một lần cho qua im lặng. Không log, không lỗi, trang cứ thế render cho người không có quyền.
Danh sách admin nằm trong một biến môi trường phía server, ngăn cách bằng dấu phẩy. Nó không bao giờ được đặt tiền tố public, vì tiền tố đó nhúng giá trị thẳng vào bundle JavaScript gửi cho trình duyệt. Danh sách email admin của bạn sẽ nằm trong file mà bất kỳ ai cũng tải được.
Hàm verifyAdmin đọc phiên từ cookie, xác thực với Supabase Auth, rồi so email với danh sách:
export async function verifyAdmin() {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) throw new Error("Unauthorized: Invalid session");
if (!isAdminEmail(user.email)) throw new Error("Forbidden: Admin access required");
return user;
}
Hai lỗi khác nhau, không phải một. "Chưa đăng nhập" và "đã đăng nhập nhưng không đủ quyền" là hai tình huống khác nhau, và gộp chúng lại thì bạn mất khả năng đưa người dùng tới đúng chỗ.
Trong codebase có một component bọc phần dashboard và ẩn nội dung khi người dùng không phải admin. Nó hữu ích: người lỡ vào nhầm thấy màn hình đăng nhập thay vì một cái bảng vỡ.
Nó không phải bảo mật. Đó là code chạy trong trình duyệt của người dùng, và người dùng thì toàn quyền với trình duyệt của họ. Nó quyết định thứ được vẽ ra, không quyết định thứ server chịu trả lời.
Ranh giới thật nằm ở chỗ dữ liệu được đọc. Site này có một client Supabase dùng service role và bỏ qua Row Level Security. File đó mở đầu bằng import "server-only", nên bất kỳ ai vô tình import nó vào một component client sẽ làm hỏng build ngay lúc đó, thay vì phát hiện ra khi khoá đã nằm trong bundle production.
Quy tắc gọn nhất mình rút ra: mỗi chỗ dùng service role phải có một lệnh verifyAdmin nhìn thấy được trong cùng một file. Không phải ở layout cha, không phải ở middleware, không phải ở một component bọc ngoài. Cùng một file.
Chưa có bình luận nào — hãy là người đầu tiên!