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:

php
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:

text
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 Budi

Baris pertama adalah versi tanpa pemeriksaan: Siti membaca catatan Budi hanya dengan mengganti URL menjadi /tugas/1. Versi aman menambahkan satu baris sebelum mengembalikan data:

php
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:

text
query tanpa scope  : select * from "tugas"
query dengan scope: select * from "tugas" where "user_id" = 2

Tugas::all() menjalankan query pertama — semua baris, semua pemilik. Kita uji endpoint index dari akun Siti:

text
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 Siti

Versi bocor mengirim seluruh tugas (termasuk milik Budi) ke browser Siti; versi aman mengembalikan hanya miliknya. Controller yang benar memfilter sejak query, bukan menyaring setelahnya:

php
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:

text
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 ):

php
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:

text
=== 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   | 200

Pola 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 .