Tuần này HN có thread gần 1000 điểm về việc các app làm bằng Lovable để lộ ảnh hộ chiếu và hơn 200K tin nhắn riêng tư. Đọc kỹ thì nguyên nhân không mới. Frontend gọi thẳng Supabase bằng anon key, mà bảng trong database thì không bật Row Level Security (RLS) hoặc có bật nhưng policy viết kiểu using (true). Ai mở DevTools, copy key ra rồi curl là đọc được toàn bộ dữ liệu.

Mình từng review vài dự án của team và freelancer dùng Cursor, Lovable, Bolt để dựng MVP, và gặp lại đúng lỗi này. AI sinh code chạy được rất nhanh, nhưng nó tối ưu cho việc "chạy được" chứ không lo chuyện "chỉ đúng người mới đọc được". Bài này là checklist mình dùng để tự audit một app Supabase trong khoảng 30 phút.

Vì sao anon key được public mà vẫn an toàn (khi có RLS)

Nhiều bạn hoảng khi thấy anon key nằm trong JS bundle. Thật ra đó là thiết kế có chủ đích. Supabase dùng PostgREST để expose schema public thành REST API, còn anon key chỉ là một JWT với role: anon. Thứ quyết định ai được đọc dòng nào là RLS policy trong PostgreSQL, không phải cái key.

flowchart LR A[Browser - anon key + user JWT] --> B[PostgREST API] B --> C{RLS enabled?} C -- Không --> D[Trả về TOÀN BỘ bảng] C -- Có --> E{Policy match auth.uid?} E -- Có --> F[Chỉ trả dòng của user] E -- Không --> G[Trả mảng rỗng] H[service_role key] -.bypass RLS.-> D

Có hai điểm chết người ở đây:

  1. RLS tắt: anon key thành chìa khóa vạn năng, đọc, sửa, xóa gì cũng được.
  2. service_role key lọt ra frontend: key này bypass RLS hoàn toàn. Mình đã thấy AI "fix lỗi permission denied" bằng cách thay anon key bằng service_role. Lỗi biến mất, còn dữ liệu thì mở toang.

Nếu project dùng format key mới (sb_publishable_... và sb_secret_...) thì vẫn áp dụng y hệt: sb_secret_ tuyệt đối không được xuất hiện ở client.

Bước 1: Tự tấn công app của mình bằng curl

Cách nhanh nhất là đóng vai attacker. Lấy URL project và anon key trong tab Network của DevTools, rồi chạy:

#!/usr/bin/env bash # audit_rls.sh - thử đọc từng bảng bằng anon key, không đăng nhập SUPABASE_URL="https://xyzcompany.supabase.co" ANON_KEY="eyJhbGciOi..." # 1. Lấy danh sách bảng từ OpenAPI spec mà PostgREST tự expose TABLES=$(curl -s "$SUPABASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" | jq -r '.definitions | keys[]') # 2. Thử đọc 1 dòng từ mỗi bảng for t in $TABLES; do count=$(curl -s "$SUPABASE_URL/rest/v1/$t?select=*&limit=1" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY" | jq 'if type=="array" then length else 0 end') [ "$count" -gt 0 ] && echo "[LEAK] $t đọc được khi chưa login" done # 3. Quét bundle build xem có lọt service_role / secret key không grep -rEo 'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' dist/ | sort -u | \ while read -r jwt; do echo "$jwt" | cut -d. -f2 | base64 -d 2>/dev/null | grep -q service_role \ && echo "[CRITICAL] service_role key trong bundle!" done grep -rq 'sb_secret_' dist/ && echo "[CRITICAL] sb_secret_ key trong bundle!" 

Cần jq (1.7+). Bảng nào in ra [LEAK] thì xử lý ngay. Lưu ý: bảng trả mảng rỗng chưa chắc đã an toàn, có thể chỉ do bảng đang trống. Bước 3 sẽ test kỹ hơn.

Song song, vào SQL Editor chạy query này để liệt kê mọi bảng chưa bật RLS:

select schemaname, tablename from pg_tables where schemaname = 'public' and rowsecurity = false; 

Nếu đã cài Supabase CLI (v2.x), chạy thêm supabase db lint --linked và mở Security Advisor trong dashboard. Hai chỗ này bắt được lỗi rls_disabled_in_public và policy_exists_rls_disabled (có policy nhưng quên bật RLS, lỗi rất hay gặp).

Bước 2: Bật RLS và viết policy cho đúng

Đây là pattern mình dùng cho bảng messages, mỗi tin nhắn chỉ người gửi và người nhận được xem:

alter table public.messages enable row level security; -- Áp dụng cả cho table owner, tránh test bằng owner rồi tưởng là ổn alter table public.messages force row level security; -- SELECT: chỉ sender hoặc receiver create policy "messages_select_own" on public.messages for select to authenticated using ((select auth.uid()) in (sender_id, receiver_id)); -- INSERT: không cho giả mạo sender_id create policy "messages_insert_self" on public.messages for insert to authenticated with check ((select auth.uid()) = sender_id); -- UPDATE: phải có cả USING lẫn WITH CHECK create policy "messages_update_own" on public.messages for update to authenticated using ((select auth.uid()) = sender_id) with check ((select auth.uid()) = sender_id); -- Index để policy không làm chậm query create index on public.messages (sender_id); create index on public.messages (receiver_id); 

Vài lỗi mình hay gặp trong code AI sinh ra:

  • using (true) cho role authenticated: bất kỳ ai đăng ký tài khoản (miễn phí, 10 giây) đều đọc được hết. Đăng nhập rồi không có nghĩa là được xem mọi thứ.
  • UPDATE thiếu with check: user có thể đổi sender_id sang người khác, hoặc tự sửa cột role từ user thành admin. Cột nhạy cảm như role hay plan nên tách sang bảng riêng mà client không có quyền ghi.
  • Viết auth.uid() không bọc select: vẫn chạy đúng nhưng bị đánh giá lại trên từng dòng. Bọc thành (select auth.uid()) để Postgres cache kết quả, bảng lớn sẽ nhanh hơn thấy rõ.
  • View bỏ qua RLS: view tạo bằng role postgres mặc định chạy với quyền owner. Trên Postgres 15+, thêm with (security_invoker = true) khi tạo view.

Bước 3: Storage, RPC và test tự động

Vụ lộ ảnh hộ chiếu nằm ở Storage, không phải ở bảng. Bucket để public = true thì ai đoán được path là tải được file, còn bucket private nhưng policy trên storage.objects lỏng thì attacker list được toàn bộ file. Ảnh giấy tờ tùy thân phải để bucket private, và chỉ cấp signed URL ngắn hạn.

sequenceDiagram participant U as User A participant API as Supabase API participant DB as Postgres + RLS U->>API: GET /storage/v1/object/kyc/userB/passport.jpg API->>DB: check policy trên storage.objects DB-->>API: folder khác auth.uid, từ chối API-->>U: 400/404 U->>API: createSignedUrl(userA/passport.jpg, 60s) API-->>U: URL hết hạn sau 60 giây

Đừng quên các hàm security definer gọi qua /rest/v1/rpc/. Hàm loại này chạy với quyền owner nên bỏ qua RLS, AI lại rất thích dùng nó để "cho chạy được". Mỗi hàm như vậy phải tự kiểm tra auth.uid() bên trong, đặt set search_path = '', và revoke execute ... from anon nếu không cần public.

Cuối cùng, viết test để lần sau AI "refactor" thì CI bắt được ngay. Mình dùng @supabase/supabase-js v2 với Vitest:

// rls.test.js - chạy trên Supabase local (supabase start) import { createClient } from '@supabase/supabase-js'; import { test, expect } from 'vitest'; const url = process.env.SUPABASE_URL; const anonKey = process.env.SUPABASE_ANON_KEY; async function loginAs(email) { const client = createClient(url, anonKey); const { error } = await client.auth.signInWithPassword({ email, password: 'test-password-123' }); if (error) throw error; return client; } test('anon không đọc được messages', async () => { const anon = createClient(url, anonKey); const { data } = await anon.from('messages').select('*'); expect(data ?? []).toHaveLength(0); }); test('user A không đọc được tin nhắn của user B', async () => { const a = await loginAs('a@test.local'); const { data: { user } } = await a.auth.getUser(); const { data } = await a.from('messages').select('sender_id, receiver_id'); for (const m of data) { expect([m.sender_id, m.receiver_id]).toContain(user.id); } }); test('user A không giả mạo được sender_id', async () => { const a = await loginAs('a@test.local'); const { error } = await a.from('messages').insert({ sender_id: '00000000-0000-0000-0000-000000000bbb', receiver_id: '00000000-0000-0000-0000-000000000ccc', body: 'spoof', }); expect(error?.code).toBe('42501'); // insufficient_privilege, bị RLS chặn }); 

Seed hai user test trong supabase/seed.sql, chạy supabase start, sau đó npx vitest run. Ba test này chưa tới 50 dòng nhưng chặn được đúng loại lỗi đã làm lộ 200K tin nhắn.

Kết luận

AI giúp mình ship nhanh gấp nhiều lần, nhưng phần phân quyền dữ liệu vẫn phải tự kiểm. Checklist áp dụng ngay hôm nay:

  1. Chạy audit_rls.sh trên production với anon key. Bảng nào trả dữ liệu khi chưa login thì sửa trước khi làm việc khác.
  2. Query pg_tables với rowsecurity = false và chạy supabase db lint. Mục tiêu là 0 bảng trong public thiếu RLS.
  3. Grep bundle tìm service_role và sb_secret_. Nếu thấy thì rotate key ngay trong dashboard, xóa khỏi code thôi là chưa đủ.
  4. Rà lại mọi policy có using (true) và mọi policy UPDATE thiếu with check.
  5. Chuyển bucket chứa giấy tờ hay ảnh cá nhân sang private, chỉ dùng signed URL.
  6. Audit các hàm security definer: tự kiểm tra auth.uid() bên trong và revoke quyền thừa của anon.
  7. Đưa RLS test vào CI, để mỗi lần AI sửa schema đều phải qua test.

Một nguyên tắc dễ nhớ: coi mọi thứ ở frontend là public, và coi RLS là lớp bảo vệ duy nhất giữa dữ liệu của user với cả Internet.