Tuần này trên Hacker News, bài viết về các app tạo bằng Lovable làm lộ thông tin hộ chiếu và hơn 200K tin nhắn riêng tư đạt gần 600 điểm. Khi đọc kỹ, đây không phải là một vụ hack tinh vi. Phần lớn trường hợp chỉ là bảng Postgres không bật Row Level Security (RLS), trong khi anon key nằm sẵn trong bundle JavaScript mà ai cũng tải về được. Mình từng review vài dự án freelance và startup ở VN dùng Supabase kết hợp AI code generation (Lovable, Bolt, Cursor...) và gặp đúng lỗi này không ít lần. Bài này đi qua bốn việc: hiểu vì sao dữ liệu bị lộ, tự kiểm tra app của mình trong 5 phút, viết policy cho đúng, và đưa bước audit vào CI để lỗi không quay lại.
Vì sao anon key không phải là bí mật
Nhiều dev mới chuyển sang Supabase vẫn giữ tư duy backend truyền thống: "key nằm trong file .env thì an toàn". Thực tế, kiến trúc của Supabase là frontend gọi thẳng vào database qua PostgREST. anon key là một JWT có role: anon, và nó được thiết kế để public. Hễ có biến VITE_SUPABASE_ANON_KEY thì giá trị đó sẽ nằm trong file JS gửi xuống browser.
Vì vậy, thứ duy nhất đứng giữa người lạ và dữ liệu của bạn là RLS policy trong Postgres:
flowchart LR A[Browser / Attacker] -->|anon key + JWT| B[PostgREST /rest/v1] B --> C{RLS enabled?} C -->|No| D[Trả về TOÀN BỘ bảng] C -->|Yes| E{Policy match auth.uid?} E -->|Yes| F[Chỉ rows của user] E -->|No| G[Empty result] Điều đáng sợ là khi bạn tạo bảng bằng SQL thuần, hoặc khi AI tool sinh migration, RLS không được bật mặc định. Dashboard có cảnh báo, nhưng nếu bạn chỉ làm việc qua prompt và migration thì rất dễ bỏ qua.
Tự "tấn công" app của mình trong 5 phút
Trước khi sửa, hãy thử đứng ở vị trí attacker. Bạn chỉ cần curl và URL production của app:
# 1. Lấy anon key và project URL từ bundle JS (cách attacker làm) curl -s https://your-app.example.com/ | grep -oE 'src="/assets/[^"]+\.js"' curl -s https://your-app.example.com/assets/index-abc123.js \ | grep -oE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' | head -1 export SB_URL="https://xyzcompany.supabase.co" export ANON="eyJhbGciOi..." # 2. Đếm số rows có thể đọc mà KHÔNG cần đăng nhập curl -s -I "$SB_URL/rest/v1/messages?select=id" \ -H "apikey: $ANON" \ -H "Authorization: Bearer $ANON" \ -H "Prefer: count=exact" \ -H "Range: 0-0" | grep -i content-range # Kết quả nguy hiểm: content-range: 0-0/214739 # Kết quả an toàn: content-range: */0 # 3. Thử ghi dữ liệu (nhiều app quên policy cho INSERT/UPDATE) curl -s -X PATCH "$SB_URL/rest/v1/profiles?id=eq.some-uuid" \ -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ -H "Content-Type: application/json" \ -d '{"role":"admin"}' Nếu bước 2 trả về một con số lớn hơn 0 cho bảng chứa dữ liệu cá nhân, app của bạn đang có lỗ hổng giống hệt các app trong bài HN. Lặp lại với mọi bảng trong schema public. Danh sách bảng có thể lấy từ endpoint $SB_URL/rest/v1/ (OpenAPI spec), và attacker cũng sẽ làm đúng như vậy.
Viết RLS policy đúng cách
Dưới đây là pattern mình dùng cho hầu hết các bảng gắn với user, chạy được trên Postgres 15+ (Supabase hiện dùng PG 15/17):
-- Bật RLS. Từ thời điểm này, không có policy = không ai truy cập được alter table public.messages enable row level security; -- SELECT: chỉ đọc tin nhắn mình gửi hoặc nhận create policy messages_select_own on public.messages for select to authenticated using ( (select auth.uid()) in (sender_id, receiver_id) ); -- INSERT: chỉ được gửi với tư cách chính mình create policy messages_insert_own on public.messages for insert to authenticated with check ( (select auth.uid()) = sender_id ); -- Index cho cột dùng trong policy, nếu không có sẽ seq scan mỗi query create index if not exists idx_messages_sender on public.messages (sender_id); create index if not exists idx_messages_receiver on public.messages (receiver_id); -- View mặc định chạy với quyền owner và BỎ QUA RLS create view public.message_feed with (security_invoker = true) as select id, sender_id, body, created_at from public.messages; Vài lỗi mình gặp thường xuyên nhất:
-
using (true): AI tool hay sinh ra dòng này để "fix" lỗi permission denied. Bật RLS mà kèm policy này thì cũng như không bật. - Chỉ có policy SELECT: thiếu
with checkcho INSERT/UPDATE khiến user có thể sửauser_idhoặcrolecủa người khác. - Dùng
auth.jwt() -> 'user_metadata'để phân quyền:user_metadatado chính user chỉnh được quasupabase.auth.updateUser(). Role admin phải lấy từapp_metadatahoặc từ một bảng riêng. - Viết
auth.uid()thay vì(select auth.uid()): cách thứ nhất bị gọi lại trên từng row. Bọc trongselectđể planner cache giá trị, trên bảng vài trăm nghìn rows có thể nhanh hơn hàng chục lần. -
service_rolekey lọt xuống frontend: key này bypass toàn bộ RLS. Grep cả repo và bundle để chắc chắn nó chỉ nằm trong Edge Functions hoặc backend. - Storage bucket để public: ảnh CCCD hay hộ chiếu upload vào bucket public thì RLS trên bảng không bảo vệ được.
Đưa vào CI: script audit tự động
Sửa một lần chưa đủ, vì lần sau bạn prompt AI tạo bảng mới thì lỗi lại quay về. Mình dùng một script Python nhỏ với psycopg 3.2, chạy trên mọi pull request có thay đổi migration:
# audit_rls.py — pip install 'psycopg[binary]>=3.2' import os import sys import psycopg NO_RLS = ''' select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity ''' WIDE_OPEN = ''' select tablename, policyname, cmd, roles from pg_policies where schemaname = 'public' and (qual = 'true' or with_check = 'true') ''' UNSAFE_VIEWS = ''' select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'v' and not coalesce(c.reloptions::text[] @> array['security_invoker=true'], false) ''' ALLOWLIST = set(os.getenv('RLS_ALLOWLIST', '').split(',')) def main() -> int: errors = [] with psycopg.connect(os.environ['DATABASE_URL']) as conn: for (t,) in conn.execute(NO_RLS): if t not in ALLOWLIST: errors.append(f'[NO RLS] public.{t}') for t, p, cmd, roles in conn.execute(WIDE_OPEN): if t not in ALLOWLIST: errors.append(f'[USING TRUE] {t}.{p} ({cmd}) roles={roles}') for (v,) in conn.execute(UNSAFE_VIEWS): errors.append(f'[VIEW BYPASS] public.{v}') for e in errors: print(e) print(f'{len(errors)} issue(s) found') return 1 if errors else 0 if __name__ == '__main__': sys.exit(main()) Trong CI, flow nên như sau:
flowchart TD A[PR có migration mới] --> B[supabase start - DB local] B --> C[supabase db reset - apply migrations] C --> D[python audit_rls.py] D -->|exit 0| E[Merge OK] D -->|exit 1| F[Block PR + in danh sách bảng lỗi] Với GitHub Actions, chỉ cần supabase/setup-cli, chạy supabase start, sau đó supabase db reset rồi gọi script với DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres. Bảng nào thật sự cần public (ví dụ countries hay pricing_plans) thì đưa vào RLS_ALLOWLIST một cách có chủ đích, đừng để nó lọt qua vì quên.
Kết luận
Vụ lộ dữ liệu tuần này không phải lỗi riêng của một tool AI nào. Đó là hậu quả của việc đưa database ra internet mà không hiểu mô hình bảo mật của nó. AI giúp ship nhanh hơn, nhưng cũng giúp ship lỗ hổng nhanh hơn. Checklist mình khuyên làm ngay hôm nay:
- Chạy lệnh
curlở trên với từng bảng trong production. Mất 5 phút nhưng có thể cứu bạn khỏi một vụ rò rỉ dữ liệu. - Bật RLS cho mọi bảng trong
public, kể cả bảng bạn nghĩ "không quan trọng". - Grep
using (true)vàuser_metadatatrong toàn bộ thư mụcsupabase/migrations/. - Thêm
security_invoker = truecho mọi view, đồng thời kiểm tra lại các bucket storage đang để public. - Đưa
audit_rls.pyvào CI để chặn regression mỗi khi AI sinh migration mới. - Rotate key nếu
service_roletừng xuất hiện trong commit hoặc bundle frontend.
Hãy coi anon key như một chiếc chìa khóa đã được phát cho cả thế giới, và RLS là ổ khóa duy nhất trên từng cánh cửa. Nếu không tự kiểm tra, sớm muộn sẽ có người khác kiểm tra giúp bạn.