Kerentanan paling sering ditemui di aplikasi Laravel dunia nyata bukan SQL injection, melainkan IDOR — Insecure Direct Object Reference. Aplikasinya mengautentikasi user dengan benar, validasi input rapi, tetapi lupa bertanya satu hal: apakah data yang diminta memang milik pemohon? Hasilnya, cukup mengganti angka di URL untuk membaca catatan pengguna lain. Chapter ini menyerang aplikasi uji kita lewat tiga pintu — detail, index, dan update — lalu menutupnya dengan policy dan matriks pengujian. Mekanisme Gate dan Policy sudah dibahas di section Authorization ; fokus kita sekarang adalah disiplin menerapkannya di setiap akses data.
Serangan IDOR pada Route Detail #
Bayangkan tabel tugas dengan kolom user_id, judul, dan isi. Route berikut validasi input-nya benar, otentikasinya benar, tetapi tidak pernah memeriksa kepemilikan:
use App\Models\Tugas;
Route::get('/tugas/{tugas}', function (Tugas $tugas) {
return response()->json($tugas->only('id', 'user_id', 'judul'));
})->middleware(['web', 'auth']);Implicit binding mengambil tugas apa pun berdasarkan {id} di URL — termasuk milik orang lain. Kita buktikan dengan dua akun, Budi pemilik data dan Siti sang penyerang:
BOCOR — Siti GET tugas milik Budi : HTTP 200 | Rahasia strategi bisnis Budi
AMAN — Siti GET tugas milik Budi : HTTP 403 |
AMAN — Budi GET tugas miliknya : HTTP 200 | Rahasia strategi bisnis BudiBaris pertama adalah versi tanpa pemeriksaan: Siti membaca catatan Budi hanya dengan mengganti URL menjadi /tugas/1. Versi aman menambahkan satu baris sebelum mengembalikan data:
abort_unless($tugas->user_id === $request->user()->id, 403);Bentuk ini disebut ownership check — pola minimal dari Resource Ownership . Untuk logika yang lebih kaya (peran admin, kolaborator), naikkan ke Policy seperti yang akan kita pakai di bawah.
Endpoint Index yang Bocor Diam-diam #
Detail yang diamankan sering diikuti index yang lupa difilter. Perhatikan perbedaan dua query ini:
query tanpa scope : select * from "tugas"
query dengan scope: select * from "tugas" where "user_id" = 2Tugas::all() menjalankan query pertama — semua baris, semua pemilik. Kita uji endpoint index dari akun Siti:
BOCOR — index dari akun Siti: HTTP 200 | 2 baris, judul pertama: Rahasia strategi bisnis Budi
AMAN — index dari akun Siti: HTTP 200 | 1 baris, judul pertama: Tugas milik SitiVersi bocor mengirim seluruh tugas (termasuk milik Budi) ke browser Siti; versi aman mengembalikan hanya miliknya. Controller yang benar memfilter sejak query, bukan menyaring setelahnya:
Route::get('/tugas', function (Request $request) {
return Tugas::where('user_id', $request->user()->id)
->orderBy('judul')
->get();
})->middleware(['web', 'auth']);Mass Update IDOR: PUT dengan Id Milik Orang Lain #
Membaca data orang lain sudah buruk; menimpanya lebih buruk lagi. Penyerang cukup mengirim PUT /tugas/1 dengan judul baru — route binding tetap menemukan tugas Budi, validasi tetap lolos, dan update() dieksekusi tanpa bertanya siapa pemiliknya:
BOCOR — Siti PUT tugas Budi : HTTP 200 | judul sekarang: Dibajak oleh Siti
AMAN — Siti PUT tugas Budi : HTTP 403 | This action is unauthorized.
judul di database (tetap) : Rahasia strategi bisnis Budi
AMAN — Budi PUT tugasnya : HTTP 200 | judul sekarang: Rahasia strategi bisnis Budi (revisi)Versi aman memakai Gate bernama update-tugas yang membandingkan pemilik sebelum update() sempat dijalankan — data Budi tidak tersentuh (definisi lengkap Gate tersedia di dokumentasi authorization
):
use Illuminate\Support\Facades\Gate;
Gate::define('update-tugas', fn (User $user, Tugas $tugas) => $tugas->user_id === $user->id);
Route::put('/tugas/{tugas}', function (Request $request, Tugas $tugas) {
Gate::authorize('update-tugas', $tugas);
$data = $request->validate(['judul' => ['required', 'string', 'max:255']]);
$tugas->update($data);
return response()->json($tugas->only('id', 'user_id', 'judul'));
})->middleware(['web', 'auth']);Eskalasi Hak Akses lewat Kolom role #
Satu varian IDOR yang senyap: aplikasi menambahkan kolom role di tabel users, lalu form profil melakukan User::create($request->all()) atau update($request->all()). Kiriman role=admin dari pengguna biasa ikut terisi bila kolom itu masuk daftar mass-assignable. Pertahanannya dua lapis: pastikan role tidak masuk $fillable (lihat Mass Assignment Protection
), dan ubah role hanya lewat route khusus yang dilindungi Gate peran admin
.
Matriks Pengujian Otorisasi #
Otorisasi paling meyakinkan diuji lewat matriks : setiap kombinasi akun × aksi × kepemilikan data. Kita jalankan untuk dua user dan dua tugas:
=== matriks uji otorisasi (HTTP code) ===
pemilik | tugas milik | show | update
-------------------------------------
Budi | Budi | 200 | 200
Budi | Siti | 403 | 403
Siti | Budi | 403 | 403
Siti | Siti | 200 | 200Pola diagonal 200-200 dengan sisanya 403 inilah bentuk “deny by default” yang benar: akses diberikan hanya karena aturan mengizinkannya, bukan karena tidak ada yang melarang. Setiap kali Anda menambahkan route baru, tambahkan baris baru ke matriks ini di Testing Laravel
.
Penyerang yang tidak bisa menyelinap masuk lewat celah otorisasi biasanya berubah taktik: menembak route login berkali-kali dengan password dari hasil kebocoran. Ekonomi serangan itulah yang kita rusak di chapter berikutnya: Rate Limiting .