Bidang Kosong Bukan Berarti Semua: Koreografi Fail-Closed
Cakupan kosong dulu dianggap ALL demi token lama. Kisah koreografi empat tahap untuk membalik middleware menjadi menolak 403 BIDANG_UNSET.
Ringkasan
Dulu kalau kolom bidang kosong, sistem anggap itu akses ALL — bahaya banget, namanya failing open. Solusinya bukan cuma hapus fallback: backfill data, sensus buktiin nol baris kosong, baru middleware nolak dengan 403 BIDANG_UNSET. Sekarang deny-by-default, dua lapis pengecekan, cuma aktor sistem userID 0 yang boleh ALL.
Log permintaan di sebuah rute administratif memperlihatkan pola yang janggal: akun dengan kolom users.bidang kosong tidak pernah ditolak. Nilai yang tidak ada itu diperlakukan sebagai cakupan terluas, ALL. Pertanyaan yang memicu perubahan sederhana saja: apa arti sebuah cakupan yang tidak diatur? Ketiadaan data, atau kepercayaan penuh?
Tebakan awal: menghapus pemberian izin implisit itu perubahan satu baris. Hapus fallback-nya, selesai.
Shim Kompatibilitas dan Koreografi Penghentiannya
Kenyataannya, perilaku kosong-berarti-ALL adalah shim kompatibilitas: token yang diterbitkan sebelum migrasi backfill 000057 harus tetap berfungsi. Menghentikannya butuh koreografi berurutan, bukan satu commit. Kirim migrasi backfill 000057 sampai setiap baris punya nilai valid, hapus produsen sah terakhir yang masih melahirkan nilai kosong, jalankan sensus langsung yang membuktikan nol baris dengan bidang kosong, dan baru setelah itu middleware boleh dibalik. Hanya setelah keempat tahap itu lewat, permintaan tanpa cakupan ditolak dengan 403 BIDANG_UNSET. Semantik otorisasi ternyata rekayasa rilis.
Detail yang mudah terlewat: commit ini tidak mengirim migrasi apa pun. Sensus sudah menunjukkan nol baris kosong, sehingga perubahan semantik cukup diterapkan di kode. Uji karakterisasi yang dulu mengunci perilaku lama dibalik menjadi kontrak baru, test fail-closed tambahan dikirim, dan deskripsi 403 di OpenAPI diperluas agar kode BIDANG_UNSET terdokumentasi untuk konsumen API.
Menurun ke Status yang Kurang Aman Adalah Pola yang Sudah Dinamai
Cakupan kosong adalah ketiadaan data, bukan pemberian izin universal. Memperlakukannya sebagai ALL jatuh ke pola yang oleh CWE dinamai Not Failing Securely atau failing open: ketika menemui kondisi error, desain yang buruk turun ke keadaan yang kurang aman daripada opsi lain yang tersedia, misalnya memakai pembatasan akses yang paling permisif; konsekuensinya, pembatasan akses yang seharusnya berlaku bisa dilewati [1]. Kerentanan otorisasi yang tidak dilakukan atau dilakukan dengan salah saat aktor mencoba mengakses sumber daya adalah kategori besarnya [3].
Ketiadaan data juga punya beberapa wajah. Nilai kosong bisa berupa string kosong, NULL, atau baris yang memang belum pernah diisi oleh onboarding yang menggantung. Ketiganya menyandikan hal yang sama, tidak ada nilai, dan ketiganya punya produsen berbeda di sejarah sistem: default lama sebelum migrasi, penyesuaian manual lewat SQL, dan akun yang baru dibuat setengah jadi. Sensus langsung yang jadi langkah penentu harus menghitung semuanya sekaligus, bukan hanya satu varian. Tanpa pengukuran itu, pembalikan middleware berjalan di atas keyakinan, bukan di atas bukti, dan keyakinan bukan fondasi yang bisa diaudit.
Perbaikannya mengikuti aturan yang dirumuskan OWASP untuk otorisasi: adopsi mentalitas deny-by-default sejak pengembangan awal dan setiap kali fungsionalitas baru terbuka, dan konfigurasi eksplisit lebih dipilih daripada mengandalkan default framework atau pustaka [2]. Dalam kasus ini artinya satu kalimat kontrak: cakupan harus bernilai eksplisit, ALL atau kode bidang, dan tidak boleh disimpulkan dari ketiadaan data.
Dua Lapis Penolakan, Satu Pengecualian yang Disengaja
Penegakan berjalan di dua lapis. Lapis pertama di middleware: ResolveBidang menolak semua rute admin dengan 403 BIDANG_UNSET ketika bidang kosong, selain menolak ketika proses pemuatan data gagal. Lapis kedua di service: DenyUnscopedActor memeriksa ulang setiap aktor nyata, dengan pengecualian yang dinyatakan terbuka di kodenya, aktor sistem userID 0 tetap memegang ALL untuk operasi internal otomatis. Dua lapis berarti satu celah di satu jalur tidak langsung menjadi akses.
Ada juga soal siapa yang boleh mengecualikan diri. Aktor sistem userID 0 memang butuh rentang pandang penuh untuk operasi internal yang tidak mewakili manusia mana pun, dan pengecualian itu sengaja ditulis di komentar kode sebagai keputusan yang terdokumentasi, bukan efek samping yang ditemukan orang belakangan. Pembeda aktor nyata versus aktor sistem di level service membuat pengecualian itu tidak bisa dipinjam: pemeriksaan DenyUnscopedActor membandingkan bidang dengan identitas aktor yang sama-sama sudah menempel pada permintaan, sehingga akun nyata tanpa bidang tidak punya jalur diam-diam ke cakupan ALL. Pengecualian yang diberi nama, komentar, dan test adalah kontrak; pengecualian yang diam-diam adalah bahan kejutan audit.
Artikel sebelumnya tentang menutup permintaan ketika penjaga tak bisa menjawab membahas sisi error: data gagal dimuat, jawabannya 503. Kasus ini kakaknya yang lebih licik: data berhasil dimuat, isinya kosong, dan sistem yang menganggap kosong sebagai semua. Dua-duanya menutup, hanya saja pintunya berbeda.
Sumber
[1] CWE-636: Not Failing Securely (Failing Open)
[2] Authorization Cheat Sheet - OWASP
[3] CWE-285: Improper Authorization