Satu-satunya Modul Admin Tanpa Write Guard
Audit modul admin menemukan tujuh dari delapan sudah punya write guard per bidang. Satu tertinggal, dan menutupnya berarti menyalin pola modul sebelah.
Ringkasan
Tabel audit proyek menunjukkan modul service types belum punya write guard. Modul ini diperbarui dengan menambahkan guard scope. Perbaikan ini membuat akses tulis lebih aman.
Tabel audit proyek portal layanan KotaPortal menampilkan satu baris yang tidak enak dilihat: dari delapan modul admin, tujuh sudah punya write guard per bidang, satu lagi belum. Modul service types — pembuatan, perubahan, dan penghapusan tipe layanan — menulis tanpa pemeriksaan scope sama sekali. Tiga fungsi tulisnya terbuka untuk siapa pun yang punya sesi admin, dan perbaikannya masuk lewat commit 7f2df04.
Insting pertama adalah menulis guard dari nol: baca konteks, ambil bidang, cocokkan dengan daftar izin, lempar error. Modul di sebelahnya sudah punya pola itu, lengkap dengan komentar yang menyebut namanya sendiri, parity dengan rentalpackage.ErrForbidden dan mediaadmin.ErrForbidden. OWASP merangkum kaidahnya pendek: terapkan kontrol akses sekali lalu gunakan ulang di seluruh aplikasi [1], karena API tanpa kontrol pada POST, PUT, dan DELETE tetap termasuk temuan yang paling sering muncul [1]. Otorisasi sendiri didefinisikan sebagai proses memastikan sebuah aksi disetujui untuk entitas tertentu [2], dan kelemahan yang paling umum justru eskalasi horizontal: pengguna terautentikasi menjangkau sumber milik pengguna lain [2].
Ambil pola guard dari modul yang sudah ada
Handler meneruskan middleware.BidangFrom(r.Context()) ke service, dan guard-nya sendiri hanya dua fungsi pendek:
func guardTypeScope(bidang, module string) error {
if canManage(bidang, module) {
return nil
}
return fmt.Errorf("%w: module di luar scope bidang Anda", ErrForbidden)
}
func canManage(bidang, module string) bool {
if module == "" {
return scope.IsGlobal(bidang)
}
return scope.In(scope.ModulesFor(bidang), module)
}Dua baris pertama canManage adalah inti keputusannya. Tipe layanan tanpa kategori disimpan dengan category_id NULL dan berstatus lintas bidang, sehingga hanya aktor global (ALL) yang boleh menulisnya. Module kosong tidak pernah berarti semua boleh; ia berarti tidak satu pun bidang biasa yang lolos. Baris kedua menutup kasus sebaliknya: bidang yang tidak dikenal memunculkan daftar module kosong, dan scope.In atas daftar kosong selalu bernilai salah, jadi tidak ada yang lolos. Deny by default persis seperti kaidah OWASP [1].
Pemetaan kategori ke module datang dari satu query JOIN di repository. Kategori yang tidak punya pasangan terbaca sebagai NULL pada pointer Module *string, sehingga baris lintas terlihat sebagai data biasa, bukan sebagai kondisi khusus yang harus ditebak. Sebelum insert, Create menyelesaikan module kategori lebih dulu, lalu memanggil guard; Update dan Delete memanggil guard sebelum menyentuh baris mana pun.
Baca tetap terbuka, tulisan yang dirapatkan
Daftar tipe layanan sengaja tidak dikunci. Form publik dan grid admin memang harus melihat semua tipe aktif, sedangkan yang dijaga adalah jalur tulis. Parameter ?module= hanya penyempit: bila nilainya di luar scope bidang atau tidak dikenal, handler mengembalikan halaman kosong tanpa menyentuh database.
Keputusan ini sempat memicu temuan kontrak. Spesifikasi OpenAPI menjanjikan respons 400 untuk nilai module di luar enum, sementara kode menjawab 200 berisi daftar kosong. Perbaikannya diarahkan ke spesifikasi, bukan ke kode: deskripsi parameter kini menyatakan bahwa nilai di luar scope atau tak dikenal dijawab daftar kosong. Kontrak harus menggambarkan perilaku yang memang diuji, bukan perilaku yang diharapkan.
Sisi tulis dijaga dua lapis. Guard menolak sebelum baris tersentuh, jawaban HTTP-nya justru dibuat seringkas mungkin. RFC 9110 menyatakan 403 berarti server mengerti permintaan tetapi menolak memenuhinya, dan alasan penolakan boleh ditulis di konten respons jika memang ingin dipublikasikan [3]. Handler memilih satu kalimat tetap tanpa menyebut module yang baru diselesaikan maupun bidang pemanggil. Detailnya tetap tersimpan di error internal untuk keperluan debug; yang sampai ke pengguna hanya kalimat generik.
Tulis jejak tanpa mengubah tanda tangan
Jejak audit ikut masuk ke commit yang sama, tetapi constructor lama tidak boleh rusak. Caranya dengan parameter variadic auditLoggers ...*audit.Logger: parameter terakhir berprefiks titik-tiga sehingga fungsi tetap dapat dipanggil tanpa argumen untuk parameter itu [4], dan ketika tidak ada argumen yang dikirim, nilainya nil [4]. Di ujung lain, metode Log memeriksa apakah receiver dalam keadaan kosong lalu langsung kembali, jauh sebelum menyentuh database, jadi audit yang tidak dikonfigurasi berarti no-op, bukan panic. Satu baris wiring di main.go menambahkan logger, sementara helper test lama tetap memanggil bentuk satu argumen tanpa perubahan.
Pengujian yang paling menentukan justru yang baru: TestServiceTypeScopedWriteForbidden, TestServiceTypeLintasAllOnly, dan TestServiceTypeListNarrowing. Keduanya membutuhkan data yang sengaja berada di luar jangkauan, sehingga seed kategori module tambahan disiapkan khusus untuk test itu. Jalankan integration test modul servicetype; output yang diharapkan seluruhnya lulus, termasuk tiga pengujian scope tersebut.
Yang berubah setelah audit itu bukan jumlah guard, melainkan cara menambah modul. Modul admin baru kini datang bersama baris yang harus diisi di matriks audit, dan baris yang kosong terlihat sebelum kodenya ditulis.
Sumber: