Apa yang sebenarnya salah
Supabase mengekspos tabel Postgres-mu lewat REST API yang dibuat otomatis. Frontend-mu berkomunikasi dengan API itu memakai anon key (disebut juga publishable key). Key itu memang dirancang untuk publik. Ia ikut terkirim di bundle JavaScript-mu, dan siapa pun bisa membacanya di DevTools.
Desain itu tidak masalah, asalkan setiap tabel mengaktifkan Row Level Security (RLS) dengan policy yang masuk akal. RLS-lah yang menentukan baris mana yang boleh dilihat oleh sebuah request. Seperti dicatat TheCyberSecGuru, mengutip dokumentasi Supabase sendiri: "Tanpa RLS, siapa pun yang memegang anon key-mu bisa mengakses semua data."
Jadi rantai kegagalannya seperti ini:
- Sebuah tool AI (atau manusia yang kelelahan) membuat create table profiles (...).
- Tidak ada yang menambahkan enable row level security.
- Aplikasi berjalan sempurna saat diuji, karena bug-nya justru terletak pada sifatnya yang terlalu permisif.
- Siapa pun yang menyalin anon key dari bundle-mu bisa menjalankan select * pada tabel itu.
Kalau kamu ingin memahami cara kerja RLS, panduan kami tentang Row Level Security Supabase membahas dasar-dasarnya. Di bawah ini kita fokus pada pengujian dan perbaikan.
Cara mengecek aplikasimu (5 menit)
1. Lihat dari luar, seperti yang dilakukan para peneliti. Buka situs live-mu, buka DevTools, dan cari supabase.co di JavaScript yang dimuat. Kamu akan menemukan URL proyekmu dan anon key di sebelahnya. Lalu, dari terminal, minta satu baris dari tabel yang kamu anggap privat:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"Arti responsnya:
- Array kosong [] atau error izin: bagus, pengguna anonim tidak bisa membaca tabel itu.
- Baris data sungguhan: kamu punya masalah yang sama dengan 7.403 situs tersebut.
Ulangi untuk setiap tabel yang menyimpan data pengguna: profiles, users, messages, orders, bookings, leads, waitlist, dan seterusnya. Tabel waitlist dan formulir kontak mudah terlupakan dan hampir selalu berisi email.
Selagi kamu memeriksa bundle, pastikan juga key yang kamu temukan adalah anon key, bukan key service_role. Service role sepenuhnya melewati RLS. Kalau key itu ada di frontend-mu, anggap sudah bocor dan rotasi sekarang juga: apa pun yang ada di bundle bisa dibaca oleh setiap pengunjung dan oleh setiap script pihak ketiga di halaman, seperti yang dialami pengguna Polymarket, dan bot sudah memburu key yang bocor. Artikel kami tentang merotasi secret aplikasimu tanpa downtime membahas proses rotasinya, dan API key yang terekspos di JavaScript sisi klien membahas apa lagi yang sering ikut masuk ke bundle.
2. Cek dari dalam. Di SQL editor Supabase, tampilkan semua tabel di schema public beserta status RLS-nya:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Setiap baris dengan rowsecurity = false bisa dibaca (dan mungkin ditulis) dengan anon key. Security Advisor di dashboard Supabase juga menandai tabel-tabel ini. Baca peringatannya, jangan diabaikan.
3. Cari kasus "RLS aktif, tapi tetap terbuka". Mengaktifkan RLS bukan garis finis. Tampilkan daftar policy-mu:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';Waspadai qual = true pada policy select yang berlaku untuk anon atau public. Itu policy yang menyatakan "semua orang boleh membaca semuanya", sama saja dengan tidak punya RLS. Tool AI kadang membuat persis policy ini supaya sebuah error hilang.
Cara memperbaikinya
Aktifkan RLS di setiap tabel:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicDengan RLS aktif dan tanpa policy, tabel terkunci: anon key tidak mendapat apa-apa. Itu default yang aman. Sekarang tambahkan hanya akses yang benar-benar kamu butuhkan.
Tulis policy yang sempit. Setup umum "pengguna hanya bisa melihat dan mengedit barisnya sendiri":
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Hal-hal yang perlu diingat:
- Batasi ke authenticated, bukan public, kecuali datanya memang benar-benar publik (artikel blog, daftar produk).
- Pisahkan baca dan tulis. Sebuah tabel bisa aman dibaca publik tapi tetap butuh insert yang dikunci.
- Tabel publik tetap membocorkan kolom. Kalau tabel profiles dibuat publik untuk menampilkan nama, kolom email atau nomor telepon di tabel yang sama juga ikut publik. Pindahkan field privat ke tabel terpisah atau ekspos view yang hanya berisi kolom yang aman.
- View bisa melewati RLS. Di Postgres, view secara default berjalan dengan hak akses pemiliknya. Buat view dengan security_invoker = true agar RLS milik pemanggil yang berlaku.
- Storage itu terpisah. Bucket publik menyajikan file kepada siapa pun yang punya URL-nya. KTP yang diunggah, invoice, dan avatar pengguna privat harus disimpan di bucket privat dengan storage policy.
Jadikan ini default, bukan pekerjaan bersih-bersih. Sampel para peneliti mencakup sepuluh bulan aplikasi baru, artinya masalah ini terus terulang di setiap tabel baru. Buat agar sulit dilupakan:
- Tambahkan aturan ke instruksi proyek tool AI-mu: setiap create table di public harus diikuti enable row level security dan policy yang eksplisit.
- Sebelum merge, periksa migration yang dihasilkan untuk create table yang tidak disertai enable row level security.
- Jalankan ulang pengecekan dari luar setiap kali schema berubah, bukan hanya saat peluncuran.
Kalau kamu memakai Firebase dan bukan Supabase, kesalahan sejenis juga ada di sana dalam bentuk security rules yang terbuka. Lihat Firebase Security Rules: berhenti membiarkan database-mu terbuka.
Kalau kamu menemukan data yang terekspos
Memperbaiki policy menghentikan pembacaan di masa depan, tapi tidak membatalkan yang sudah terjadi. Jujurlah pada dirimu sendiri tentang apa yang bisa dijangkau dan selama berapa lama. Periksa log API Supabase untuk pembacaan massal yang tidak biasa dari IP yang tidak dikenal, rotasi semua secret yang tersimpan di tabel yang bisa dibaca, dan cari tahu kewajiban notifikasimu jika data pribadi seperti email, dokumen identitas, atau detail pembayaran terekspos. Di Uni Eropa, kemungkinan besar aturan pelanggaran data GDPR berlaku.
Checklist singkat
Sebelum menganggapnya selesai, pastikan bahwa:
- Setiap tabel di public punya rowsecurity = true.
- Tidak ada policy select yang memakai using (true) pada tabel berisi data privat.
- Policy dibatasi ke authenticated dan terikat pada auth.uid().
- Kolom privat tidak berada di tabel yang bisa dibaca publik.
- View memakai security_invoker = true.
- Hanya anon key yang ada di frontend-mu, tidak pernah service_role.
- File privat disimpan di storage bucket privat.
- Kamu menguji ulang dari luar setiap kali schema berubah.
Cek aplikasimu dari luar
Checklist keamanan untuk aplikasi buatan AI kami membahas hal-hal lain yang perlu dicek sebelum peluncuran. Bagian yang tidak nyaman dari riset ini adalah betapa biasa metodenya: key publik, request read-only, data kembali. Itulah persis sudut pandang penyerang, dan itulah sudut pandang yang seharusnya rutin kamu cek.
AppSafe memindai aplikasi live-mu dari luar. Scan penuh terverifikasi mencakup pengecekan read-only untuk database Supabase dan Firebase yang terbuka serta mencari API key yang terekspos di bundle JavaScript-mu, dan scan terjadwal bisa memberimu peringatan saat ada yang berubah setelah deploy. Penasaran seperti apa laporannya? Lihat contoh laporan.
Jalankan scan gratis untuk aplikasimu sekarang dan cari tahu apa yang bisa dilihat orang asing sebelum orang lain melakukannya.
