Ada satu kelas kerentanan yang muncul bukan karena pemeriksaan yang salah, melainkan karena pemeriksaan yang lupa ditulis: IDOR (Insecure Direct Object Reference). Polanya sederhana — aplikasi mempercayai angka pada URL. Budi membuka /posts/99/edit, mengganti menjadi /posts/100/edit, dan tiba-tiba bisa membaca atau mengubah tulisan Siti, cukup karena route binding menemukan record-nya dan controller tidak membandingkan pemilik. Bab ini membedah celah tersebut dengan exploit sungguhan, menutupnya lewat policy, lalu menambah dua lapis pengaman tambahan: scoped binding untuk nested resource dan global scope untuk daftar data milik sendiri.
Anatomi Celah IDOR #
Berikut controller yang kelihatannya normal — route model binding mengambil post, lalu memperbaruinya:
class IdorPostController extends Controller
{
public function update(Request $request, Post $post): JsonResponse
{
$post->update(['judul' => (string) $request->input('judul', $post->judul)]);
return response()->json(['status' => 'diperbarui', 'judul' => $post->judul]);
}
}Tidak ada satu baris pun yang bertanya “apakah tulisan ini milik pengguna yang sedang login?”. Kerangka route model binding sendiri tidak bersalah — fitur itu hanya alat pencari record. Kesalahan sebenarnya ada pada asumsi bahwa “sudah login” berarti “boleh semuanya”; padahal authentication dan ownership adalah dua pertanyaan berbeda. Feature test memainkan peran penyerang — Siti mengirim PUT ke tulisan milik Budi:
$budi = User::factory()->create([
'name' => 'Budi', 'email' => '[email protected]', 'role' => 'penulis',
]);
$post = Post::create([
'user_id' => $budi->id,
'judul' => 'Tulisan Rahasia Budi',
'isi' => 'Isi tulisan Budi.',
]);
$siti = User::factory()->create([
'name' => 'Siti', 'email' => '[email protected]', 'role' => 'penulis',
]);
$response = $this->actingAs($siti)
->putJson("/idor/posts/{$post->id}", ['judul' => 'Dibajak Siti']);
echo 'siti PUT /idor/posts/{post} status: '.$response->status()."\n";
echo 'body: '.$response->getContent()."\n";Output yang Dihasilkan:
siti PUT /idor/posts/{post} status: 200
body: {"status":"diperbarui","judul":"Dibajak Siti"}Status 200 — bukan 403. Judul “Tulisan Rahasia Budi” kini berubah menjadi “Dibajak Siti” tanpa satu pun peringatan dari server. Route model binding bekerja untuk semua orang karena tugasnya hanya mencari Post ber-id tertentu, bukan mengurusi siapa pemiliknya.
Menutup dengan Policy #
Perbaikannya adalah baris yang hilang tadi. Controller resmi kita memanggil policy sebelum menyentuh data:
public function update(Request $request, Post $post): JsonResponse
{
Gate::authorize('update', $post);
$post->update(['judul' => (string) $request->input('judul', $post->judul)]);
return response()->json(['status' => 'diperbarui', 'judul' => $post->judul]);
}Policy yang menjawab adalah PostPolicy::update() dari chapter Policies
: return $user->id === $post->user_id; — satu perbandingan sederhana yang memutuskan segalanya. Request yang sama kini menghasilkan:
siti PUT /posts/{post} status: 403Siti tetap bisa memakai endpoint untuk tulisannya sendiri — yang ditolak hanyalah tulisan yang bukan miliknya. Inilah mengapa keputusan ownership sebaiknya hidup di policy: satu method, dipakai ulang oleh controller, middleware can:, dan Blade @can tanpa logika ganda.
Scoped Binding untuk Nested Resource #
Pola URL seperti /users/{user}/posts/{post} punya jebakan sendiri: tidak ada jaminan {post} adalah milik {user} di segmen sebelumnya. Method scopeBindings() pada route menuntut konsistensi itu lewat relasi model:
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
return response()->json(['judul' => $post->judul]);
})->scopeBindings();Framework kini menyelesaikan {post} lewat relasi $user->posts() — bukan query global. Ujinya:
echo 'GET /users/{siti}/posts/{milik siti} status: '
.$this->get("/users/{$siti->id}/posts/{$post->id}")->status()."\n";
echo 'GET /users/{budi}/posts/{milik siti} status: '
.$this->get("/users/{$budi->id}/posts/{$post->id}")->status()."\n";Output yang Dihasilkan:
GET /users/{siti}/posts/{milik siti} status: 200
GET /users/{budi}/posts/{milik siti} status: 404Perhatikan status kedua: 404, bukan 403. Bagi user Budi, tulisan itu “tidak ada” di dalam ruang lingkup miliknya — jawaban yang justru lebih aman karena tidak membocorkan keberadaan resource milik orang lain.
Global Scope untuk Data Milik Sendiri #
Untuk halaman daftar — inbox, riwayat, catatan — filter user_id idealnya tidak bergantung pada kehati-hatian setiap query. Global scope menempelkannya pada model:
class Note extends Model
{
protected static function booted(): void
{
static::addGlobalScope('owner', function (Builder $builder): void {
$builder->where('user_id', auth()->id());
});
}
}Mulai sekarang setiap Note::all(), Note::count(), sampai route model binding Note otomatis tersaring milik pengguna yang login. Feature test dengan tiga catatan — dua milik Budi, satu milik Siti:
echo 'budi GET /catatan body: '
.$this->actingAs($budi)->get('/catatan')->getContent()."\n";
echo 'siti GET /catatan body: '
.$this->actingAs($siti)->get('/catatan')->getContent()."\n";Output yang Dihasilkan:
budi GET /catatan body: ["Catatan belajar gate","Catatan belajar policy"]
siti GET /catatan body: ["Catatan pribadi Siti"]Masing-masing hanya melihat miliknya, meskipun controller tidak menulis satu kondisi where pun.
Ownership kini terjaga dari empat arah: policy menjaga per-aksi, scoped binding menjaga nested resource, global scope menjaga daftar, dan angka 403/404 menjadi bukti. Satu langkah terakhir menyempurnakan semuanya: membungkus aturan-aturan ini dalam feature test yang berjalan otomatis — inti dari chapter penutup section ini.