SQL injection menyuntikkan perintah ke query; mass assignment jauh lebih halus — penyerang menyuntikkan kolom ke data yang aplikasi simpan dengan sukarela. Kerentanan ini tidak butuh karakter aneh atau tanda kutip licik: cukup satu key ekstra bernama is_admin yang diselipkan ke form yang tampak polos, dan controller yang mempercayai $request->all(). Chapter Mass Assignment pada section Eloquent ORM
sudah membahas mekanisme fillable, guarded, dan forceFill(); di sini kita membedah sisinya yang gelap — eksploitasi nyata level HTTP, tangga perlindungannya, dan pola defense in depth yang menempatkan filter di dua lapis.
Skenario Serangan: Field Tersembunyi #
Bayangkan form edit profil dengan dua input biasa: name dan email. Penyerang yang sudah login membuka DevTools, menyuntikkan satu baris sebelum submit:
<input type="hidden" name="is_admin" value="1">Di sisi controller, kode yang tampak wajar menunggu:
Route::put('/profil/{id}', function (Illuminate\Http\Request $request, int $id) {
$pengguna = PenggunaTerbuka::findOrFail($id);
// VERSI RENTAN: semua input dipercaya
$pengguna->update($request->all());
return response()->json([
'id' => $pengguna->id,
'name' => $pengguna->name,
'is_admin' => (bool) $pengguna->fresh()->is_admin,
]);
})->name('profil.update');Model PenggunaTerbuka adalah model ceroboh yang tidak menjaga kolom apa pun — protected $guarded = []; tanpa $fillable. Submit form dengan field tersembunyi itu menghasilkan:
HTTP 200 — {"id":4,"name":"Siti","is_admin":true}Serangan berhasil: pengguna biasa kini admin. Perhatikan bahwa request ini melewati CSRF dengan mulus — token valid, session valid, karena pelakunya memang pengguna yang sah. Mass assignment mengeksploitasi kepercayaan aplikasi pada isi request, bukan asalnya.
Tangga Perlindungan Eloquent #
Perilaku model terhadap key asing berbeda-beda menurut konfigurasinya — kita uji ketiganya dengan input penyerang yang sama (name, email, is_admin):
use Illuminate\Database\Eloquent\Model;
class Pengguna extends Model
{
protected $table = 'users';
protected $fillable = ['name', 'email', 'password'];
}
class PenggunaTerbuka extends Model
{
protected $table = 'users';
protected $guarded = [];
}
$inputPenyerang = ['name' => 'Siti', 'email' => '[email protected]', 'is_admin' => 1];
// skenario 1: model ber-whitelist
Pengguna::findOrFail($siti->id)->update($inputPenyerang);
// skenario 2: model tanpa proteksi
PenggunaTerbuka::findOrFail($siti->id)->update($inputPenyerang);
// skenario 3: forceFill pada model ber-whitelist
Pengguna::findOrFail($siti->id)->forceFill(['is_admin' => 1])->save();Output yang Dihasilkan:
=== 1. Whitelist fillable yang benar ===
is_admin di database: false (is_admin dibuang diam-diam)
=== 2. Model tanpa proteksi ($guarded = []) ===
is_admin di database: true (SERANGAN BERHASIL!)
=== 3. forceFill mem-bypass fillable ===
is_admin di database: true (bypass whitelist!)Tiga keadaan ini memetakan tangga perlindungan secara utuh:
| Konfigurasi model | Nasib key is_admin |
|---|---|
$fillable berisi kolom form | Dibuang diam-diam — key tidak ada di whitelist |
$guarded = [] (atau $fillable terlalu luas) | Tersimpan — serangan berhasil |
forceFill() / User::unguard() | Tersimpan — melewati semua pemeriksaan |
Kasus keempat yang belum terlihat di atas: model tanpa $fillable apa pun dan $guarded yang tetap default ['*'] akan melempar MassAssignmentException — penolakan paling eksplisit yang justru sehat, karena bug ketik pun langsung ketahuan. Perilaku ini sudah kita buktikan di chapter Mass Assignment
. Di Laravel 13, daftar whitelist juga dapat dideklarasikan lewat atribut #[Fillable(['name', 'email', 'password'])] di atas class model — semantiknya sama persis dengan property.
Dua strategi whitelist pun tersedia untuk tim. Pendekatan $fillable yang kita pakai di atas menyatakan “hanya kolom ini yang terbuka” per model — cocok untuk model aktif yang sering menerima form. Sebaliknya, strategi lock-down dengan $guarded = ['*'] menutup semuanya secara bawaan, dan hanya model yang memang mengekspos form yang diberi $fillable — pilihan yang cocok untuk aplikasi dengan banyak model internal yang tidak pernah disentuh input user.
Surface API: Klien JSON Bisa Mengirim Key Ekstra #
Form HTML bukan satu-satunya pintu. Klien API mengirim body JSON, dan $request->all() menyatukan semuanya tanpa membedakan asal. Route versi rentan yang sama diuji dengan body JSON murni:
=== Klien JSON API mengirim key ekstra is_admin ===
HTTP 200 — {"id":4,"is_admin":true}Tidak ada field tersembunyi yang perlu disuntikkan lewat DevTools — cukup menambah satu key di JSON payload. Ini membuat PUT/PATCH endpoint menjadi surface mass assignment yang paling mudah diserang, justru karena terlihat seperti “sekadar data”.
Pertahanan Berlapis: only() Dulu, fillable Terakhir #
Perbaikannya menutup dua lapis sekaligus — controller hanya mengambil kolom yang memang bagian form, dan model tetap memegang whitelist:
Route::put('/profil/{id}', function (Illuminate\Http\Request $request, int $id) {
$pengguna = Pengguna::findOrFail($id);
// VERSI AMAN: hanya kolom form yang diambil
$pengguna->update($request->only(['name', 'email']));
return response()->json([
'id' => $pengguna->id,
'name' => $pengguna->name,
'is_admin' => (bool) $pengguna->fresh()->is_admin,
]);
})->name('profil.update');Output yang Dihasilkan (serangan form dan JSON yang sama):
HTTP 200 — {"id":4,"name":"Siti","is_admin":false}
HTTP 200 — {"id":4,"is_admin":false}$request->only() memfilter input lebih awal sehingga key asing tidak pernah menyentuh model; $fillable menjadi benteng terakhir bila lapis pertama lupa diterapkan. Dua lapis ini tidak saling menggantikan — saat developer baru menambahkan kolom ke only() tanpa sengaja memasukkan is_admin, whitelist model masih menahannya. Perilaku lengkap mass assignment di Eloquent terdokumentasi di dokumentasi Eloquent
.
Mass assignment menjaga kolom. Pertanyaan berikutnya: bagaimana kita menjaga rahasia yang menentukan siapa pengguna itu — passwordnya? Kita bahas hashing, kebijakan kompleksitas, dan rate limit di chapter Password Security .