Melampaui Fungsionalitas: Mengapa Hardening Keamanan Begitu Krusial
Pada Part 9, kita berhasil menuntaskan bug kalkulasi nilai CBT dari laporan komunitas dan menyiapkan pipeline otomatis GitHub Actions CI. Setelah pengujian otomatis berjalan stabil, perhatian kami beralih ke tantangan arsitektur yang jauh lebih mendasar: otorisasi dan pembatasan akses data (access control).
Dalam platform manajemen pembelajaran (LMS) dan ujian daring (CBT) berbasis sekolah, berbagai peran pengguna hidup berdampingan dalam satu database yang sama:
- Admin: Mengelola master data sekolah, guru, dan kelas.
- Guru: Merancang soal ujian, mengoreksi jawaban, dan mengunggah materi ajar.
- Siswa: Mengikuti ujian berbasis waktu, mengunduh materi, dan berdiskusi di forum tugas.
Ketika entitas seperti Examination, Question, Assignment, dan LearningMaterial diakses oleh berbagai role, muncul pertanyaan kritis:
Bisakah Guru A mengedit atau menghapus soal milik Guru B? Bisakah siswa dari Kelas 10-A membaca materi atau soal ujian milik Kelas 10-B hanya dengan mengubah angka ID pada URL peramban mereka?
Tanpa pembatasan otorisasi yang ketat di level backend, jawabannya sering kali adalah bisa. Celah kerentanan ini dikenal sebagai IDOR (Insecure Direct Object Reference), yang menempati peringkat A01:2021 โ Broken Access Control pada OWASP Top 10.
Melalui Pull Request #18: "Resolve IDOR vulnerabilities and harden authorization across student, teacher, and admin modules", kami melakukan audit keamanan menyeluruh terhadap aplikasi Laravel 12 dan Livewire 3 ini. Kami membangun pertahanan berlapis pada 30 file, merancang 6 kelas Laravel Policy khusus, serta menyusun 411 baris automated security test suite.
Berikut adalah kupas tuntas seluruh celah keamanan yang ditemukan, skenario eksploitasinya, dan langkah mitigasi arsitektural yang kami terapkan.
Threat Modeling: 5 Vektor Kerentanan Sebelum PR #18
Mari kita petakan area permukaan serangan (attack surface) yang sempat terbuka sebelum perbaikan:
1. Manipulasi Antar Guru pada Soal & Ujian (Teacher IDOR)
Pada komponen Livewire QuestionIndex, manipulasi soal dilakukan langsung berpatokan pada primary key ID tanpa pembatasan kepemilikan guru:
PHP
// SEBELUM: Query global berbahaya tanpa filter ID guru
public function edit(int $id): void
{
$q = Question::findOrFail($id); // Siapa pun dengan role guru bisa memuat soal apa pun!
$this->editingId = $id;
// ...
}
public function delete(): void
{
Question::findOrFail($this->deletingId)->delete(); // Guru bisa menghapus soal milik guru lain!
}
Karena pemanggilan Question::findOrFail($id) mencari ke seluruh tabel tanpa filter teacher_id milik guru yang sedang login, Guru B cukup memicu aksi Livewire dengan ID soal Guru A untuk mengubah atau menghapusnya.
Kelemahan serupa juga terjadi pada halaman /teacher/exams/{id}/monitor dan /teacher/exams/{id}/print, di mana sistem hanya mengecek apakah pengguna memiliki role guru, tanpa memastikan apakah guru tersebut adalah pemilik sah dari ujian yang bersangkutan.
2. Akses Materi & Tugas Lintas Kelas oleh Siswa (Student IDOR)
Siswa ditugaskan ke kelas tertentu (misal: Kelas X-A). Guru sering kali menyiapkan materi dan tugas beberapa hari lebih awal dalam status draft (is_published = false).
Dua masalah fatal muncul di sini:
- Kebocoran Materi Antar Kelas: Jika siswa mengubah parameter URL dari
/student/materials/12menjadi/student/materials/13, mereka dapat membuka materi milik kelas lain. - Bypass Pratinjau Draft: Siswa bahkan bisa membaca materi yang masih dalam bentuk draf sebelum tanggal publikasi resminya.
- Pengumpulan Tugas Tak Sah: Siswa dapat memanggil fungsi
Assignments::openSubmit($assignmentId)untuk tugas kelas lain, atau mengumpulkan tugas yang sudah melewati tenggat waktu padahal opsiallow_late_submission = false.
3. Cross-Exam Question Poisoning pada Ujian CBT
Saat ujian berlangsung, method ExamStart::saveAnswer(int $questionId, string $answer) menangani penyimpanan jawaban siswa:
PHP
// SEBELUM: Tidak ada verifikasi kecocokan ujian dan soal
public function saveAnswer(int $questionId, string $answer): void
{
if (!$this->attempt) return;
$this->answers[$questionId] = $answer;
ExamAnswer::updateOrCreate(
['exam_attempt_id' => $this->attempt->id, 'question_id' => $questionId],
['answer' => $answer]
);
}
Siswa yang sedang mengerjakan Ujian 1 dapat memodifikasi payload network dan mengirim $questionId dari Ujian 2 (atau ujian pekan depan). Database akan langsung menyimpan jawaban tersebut dan mengaitkannya ke attempt yang sedang aktif.
4. Kebocoran Kunci Jawaban di Snapshot Livewire
Model Eloquent Laravel secara default menyertakan seluruh kolom database saat diserialisasi ke JSON. Pada model Question:
correct_answer(misal:'B')explanation(pembahasan soal)
Ketika Livewire mengirim state komponen ke browser siswa, atribut-atribut ini ikut terkirim dalam payload snapshot. Siswa yang membuka tab Network pada Chrome DevTools atau mengecek object window.Livewire dapat mengekstrak kunci jawaban seluruh soal secara instan.
5. Celah CSRF pada Fitur Impersonasi Admin
Fitur impersonasi admin dirancang agar administrator dapat mendiagnosis kendala akun siswa/guru secara langsung:
- Rute lama menggunakan metode
GET:/admin/impersonate/{userId}dan/admin/stop-impersonate. - Karena berjenis
GET, endpoint ini sama sekali tidak memiliki perlindungan CSRF. Situs eksternal berbahaya bisa memuat tag<img src="https://elearning.sekolah.id/admin/impersonate/42">dan secara diam-diam memindahkan sesi admin. - Tidak ada validasi yang melarang admin meng-impersonate akun admin lain atau akunnya sendiri.
- Sesi tidak diregenerasi saat beralih akun, membuka potensi serangan session fixation.
6. Kebocoran Kanal Broadcast WebSocket
Pada berkas routes/channels.php:
PHP
// SEBELUM: Verifikasi terlalu longgar
Broadcast::channel('assignment.{assignmentId}', function ($user, $assignmentId) {
if ($user->hasRole('guru')) {
return true; // Setiap guru bisa mendengar obrolan tugas kelas guru lain!
}
// ...
});
Guru mana pun bisa berlangganan ke kanal WebSocket obrolan tugas dan membaca pesan pribadi antara siswa dan guru dari kelas lain.
Solusi: Arsitektur Keamanan 4 Lapis (Defense in Depth)
Untuk menutup seluruh celah di atas, kami merancang model pengamanan 4 lapis (Defense in Depth) yang bekerja secara simultan untuk memastikan setiap permintaan pengguna divalidasi secara komprehensif:
Alur eksekusi detail antar-komponen saat memproses permintaan pengguna dapat dilihat pada diagram urutan (sequence diagram) berikut:
Lapis 1: Penerapan Kebijakan Otorisasi (Laravel Policies)
Kebijakan otorisasi di Laravel paling tepat dipusatkan melalui kelas Policy. Pada PR #18, kami mendefinisikan 6 policy dan mendaftarkannya secara eksplisit di AppServiceProvider.php:
PHP
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Gate::policy(Examination::class, ExaminationPolicy::class);
Gate::policy(Assignment::class, AssignmentPolicy::class);
Gate::policy(LearningMaterial::class, LearningMaterialPolicy::class);
Gate::policy(Question::class, QuestionPolicy::class);
Gate::policy(ExamAttempt::class, ExamAttemptPolicy::class);
Gate::policy(AssignmentSubmission::class, AssignmentSubmissionPolicy::class);
}
ExaminationPolicy: Pemisahan Aturan Guru dan Siswa
PHP
// app/Policies/ExaminationPolicy.php
namespace App\Policies;
use App\Models\ExamAttempt;
use App\Models\Examination;
use App\Models\User;
class ExaminationPolicy
{
public function view(User $user, Examination $examination): bool
{
if ($user->hasRole('admin')) return true;
if ($user->hasRole('guru')) {
return (int) $examination->teacher_id === (int) $user->teacher?->id;
}
if ($user->hasRole('siswa')) {
return $examination->status === 'published'
&& (int) $examination->classroom_id === (int) $user->student?->classroom_id;
}
return false;
}
public function monitor(User $user, Examination $examination): bool
{
if ($user->hasRole('admin')) return true;
if ($user->hasRole('guru')) {
return (int) $examination->teacher_id === (int) $user->teacher?->id;
}
return false;
}
public function grade(User $user, Examination $examination, ?ExamAttempt $attempt = null): bool
{
if ($user->hasRole('admin')) return true;
if ($user->hasRole('guru')) {
$isOwner = (int) $examination->teacher_id === (int) $user->teacher?->id;
if (!$isOwner) return false;
// Validasi krusial: Pastikan attempt benar-benar milik ujian ini!
if ($attempt !== null && (int) $attempt->examination_id !== (int) $examination->id) {
return false;
}
return true;
}
return false;
}
}
Perhatikan pengecekan pada method grade(): tidak hanya guru harus menjadi pemilik ujian, namun jika argumen $attempt dilewatkan, ID ujian pada attempt harus identik dengan ID ujian yang sedang dinilai.
Lapis 2: Scoped Eloquent Queries pada Level Database
Prinsip defense in depth mengajarkan kita untuk tidak hanya mengandalkan satu titik pertahanan. Selain pemanggilan $this->authorize(...), setiap query database di level komponen harus dibatasi langsung dengan ID guru pemilik.
Pada app/Livewire/Teacher/QuestionIndex.php:
PHP
// SESUDAH: Query terisolasi dengan filter teacher_id
public function edit(int $id): void
{
$teacherId = auth()->user()->teacher?->id;
$q = Question::whereHas('examination', fn ($query) =>
$query->where('teacher_id', $teacherId)
)->findOrFail($id);
$this->editingId = $id;
$this->examination_id = (string) $q->examination_id;
// ...
}
public function delete(): void
{
$teacherId = auth()->user()->teacher?->id;
Question::whereHas('examination', fn ($query) =>
$query->where('teacher_id', $teacherId)
)->findOrFail($this->deletingId)->delete();
$this->showDeleteModal = false;
session()->flash('success', 'Soal berhasil dihapus.');
}
Bila penyerang mencoba mengirimkan ID soal milik guru lain, findOrFail() langsung melemparkan ModelNotFoundException (HTTP 404), mencegah manipulasi data apa pun.
Lapis 3: Proteksi State Livewire 3 dengan Atribut #[Locked]
Livewire memelihara state antarkomponen dengan mengirim snapshot terenkripsi ke browser. Tanpa proteksi khusus, properti publik dapat dimanipulasi oleh klien sebelum memicu aksi.
Kami menambahkan atribut #[Locked] pada properti identitas di seluruh komponen Livewire:
PHP
// app/Livewire/Student/Assignments.php
use Livewire\Attributes\Locked;
class Assignments extends Component
{
public bool $showSubmit = false;
#[Locked]
public ?int $submittingId = null; // Klien tidak dapat mengubah nilai ini secara ilegal!
public function openSubmit(int $assignmentId): void
{
$assignment = Assignment::findOrFail($assignmentId);
$this->authorize('submit', $assignment); // Cek policy!
$this->submittingId = $assignment->id;
$this->showSubmit = true;
}
public function submit(): void
{
$this->validate();
if (!$this->submittingId) return;
$assignment = Assignment::findOrFail($this->submittingId);
$this->authorize('submit', $assignment); // Otorisasi ulang sebelum menyimpan!
// Simpan tugas secara aman
}
}
Mencegah Keracunan Jawaban pada ExamStart.php
Pada app/Livewire/Student/ExamStart.php, kami menambahkan dua validasi krusial saat siswa menyimpan jawaban:
PHP
public function saveAnswer(int $questionId, string $answer): void
{
if (!$this->attempt || $this->attempt->status !== 'in_progress') {
return;
}
// 1. Verifikasi bahwa durasi ujian belum habis
if ($this->attempt->started_at) {
$durationDeadline = $this->attempt->started_at->copy()->addMinutes($this->examination->duration_minutes);
$deadline = $durationDeadline->lt($this->examination->end_at) ? $durationDeadline : $this->examination->end_at;
if (now()->gt($deadline)) {
$this->finishExam();
return;
}
}
// 2. KUNCI: Pastikan ID soal benar-benar terdaftar di ujian ini
$validQuestion = $this->examination->questions()->where('id', $questionId)->exists();
if (!$validQuestion) {
return; // Abaikan soal asing!
}
$this->answers[$questionId] = $answer;
ExamAnswer::updateOrCreate(
['exam_attempt_id' => $this->attempt->id, 'question_id' => $questionId],
['answer' => $answer]
);
}
Lapis 4: Menyembunyikan Kunci Jawaban & Mengamankan Impersonasi
1. Menyembunyikan Jawaban pada Model Question.php
Agar kunci jawaban tidak terbawa ke browser melalui payload snapshot Livewire:
PHP
// app/Models/Question.php
class Question extends Model
{
protected $hidden = [
'correct_answer',
'explanation',
];
}
Dengan konfigurasi ini, atribut correct_answer dan explanation tidak akan pernah dikonversi ke JSON di sisi browser siswa, namun tetap dapat diakses di backend PHP oleh modul penilaian guru.
2. Restrukturisasi Fitur Impersonasi Admin
Kami merombak rute impersonasi dari GET menjadi rute POST yang dilindungi token CSRF:
PHP
// routes/admin.php
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
Route::post('/impersonate/{userId}', [ImpersonateController::class, 'start'])
->name('admin.impersonate.start');
});
Route::post('/admin/stop-impersonate', [ImpersonateController::class, 'stop'])
->middleware(['auth'])
->name('impersonate.stop');
Di dalam ImpersonateController.php:
PHP
public function start(Request $request, int $userId)
{
$admin = Auth::user();
if (!$admin || !$admin->hasRole('admin')) {
abort(403, 'Akses ditolak.');
}
$target = User::findOrFail($userId);
// Mencegah admin meng-impersonate sesama admin atau dirinya sendiri
if ($target->hasRole('admin') || $target->id === $admin->id) {
abort(403, 'Tidak dapat melakukan impersonasi sesama admin atau akun sendiri.');
}
session()->put('impersonating_admin_id', $admin->id);
session()->regenerate(); // Mencegah session fixation
Auth::login($target);
if ($target->hasRole('guru')) return redirect('/teacher/dashboard');
if ($target->hasRole('siswa')) return redirect('/student/dashboard');
return redirect('/');
}
Di Blade, tautan teks diubah menjadi form POST ber-CSRF dengan indikator visual yang jelas:
BLADE
<form method="POST" action="{{ route('impersonate.stop') }}" class="inline">
@csrf
<button type="submit" class="inline-flex items-center gap-1 bg-white text-amber-700 px-3 py-1 rounded-md text-xs font-bold hover:bg-amber-50 transition cursor-pointer">
โ Kembali ke Admin
</button>
</form>
Automated Security Regression Suite: IdorTest.php
Untuk memastikan perbaikan keamanan ini tidak regresi saat ada kontribusi baru di masa mendatang, kami menyusun rangkaian automated test di tests/Feature/Security/IdorTest.php:
tests/Feature/Security/IdorTest.php (411 baris)
โ test_teacher_cannot_monitor_another_teachers_exam
โ test_teacher_cannot_grade_another_teachers_exam
โ test_teacher_cannot_print_another_teachers_exam
โ test_teacher_cannot_edit_or_delete_another_teachers_question
โ test_teacher_cannot_view_or_grade_another_teachers_assignment_submissions
โ test_student_cannot_view_unpublished_material_or_material_from_another_classroom
โ test_student_cannot_submit_assignment_of_another_classroom_or_after_due_date
โ test_student_cannot_save_answers_to_questions_from_different_exam
โ test_admin_cannot_impersonate_another_admin
Contoh pengujian pencegahan manipulasi soal antar ujian:
PHP
public function test_student_cannot_save_answers_to_questions_from_different_exam(): void
{
[$t1User, $t1] = $this->createTeacher('[email protected]');
$classA = $this->createClassroom('X-A');
$subject = Subject::create(['name' => 'Kimia', 'code' => 'KIM']);
[$sUser, $student] = $this->createStudent($classA->id, '[email protected]');
$exam1 = Examination::create([ /* ... Ujian 1 aktif ... */ ]);
$exam2 = Examination::create([ /* ... Ujian 2 ... */ ]);
$qExam2 = Question::create([
'examination_id' => $exam2->id,
'question_text' => 'Soal dari Ujian 2',
'options' => ['A', 'B'],
'correct_answer' => 'A',
]);
$attempt = ExamAttempt::create([
'examination_id' => $exam1->id,
'student_id' => $student->id,
'started_at' => now(),
'status' => 'in_progress',
]);
// Simulasi siswa mencoba mengirim jawaban untuk soal milik Ujian 2 saat tes Ujian 1
Livewire::actingAs($sUser, 'web')
->test(ExamStart::class, ['examination' => $exam1])
->call('saveAnswer', $qExam2->id, 'A');
// Pastikan database tidak terkontaminasi oleh soal asing
$this->assertDatabaseMissing('exam_answers', [
'exam_attempt_id' => $attempt->id,
'question_id' => $qExam2->id,
]);
}
Semua pengujian berjalan 100% hijau di pipeline CI GitHub Actions.
Ringkasan Perubahan: Sebelum vs. Sesudah PR #18
| Modul / Vektor | Sebelum PR #18 | Sesudah PR #18 |
|---|---|---|
| Manajemen Soal Ujian | Question::findOrFail($id) mengizinkan edit/hapus lintas guru | Scoped query ke teacher_id + pengecekan QuestionPolicy |
| Monitor & Cetak Ujian | Semua user ber-role guru bisa membuka ujian guru mana pun | ExaminationPolicy@monitor membatasi hanya untuk pembuat ujian |
| Koreksi Hasil Ujian | Guru bisa mengoreksi attempt dari ujian guru lain | ExaminationPolicy@grade memverifikasi kepemilikan ujian dan attempt |
| Materi Pembelajaran | Siswa bisa melihat materi draf & materi milik kelas lain | LearningMaterialPolicy memverifikasi is_published dan classroom_id |
| Pengumpulan Tugas | Siswa bisa mengumpulkan ke kelas lain atau sesudah batas waktu | AssignmentPolicy@submit memvalidasi kelas, status, dan tenggat |
| Jawaban CBT | Siswa bisa menyimpan jawaban soal dari ujian lain | Verifikasi $examination->questions()->where('id', $questionId)->exists() |
| Serialisasi Model Soal | Kunci jawaban bocor di JSON snapshot browser | Ditekan via Question::$hidden = ['correct_answer', 'explanation'] |
| Impersonasi Admin | Rute GET, tanpa CSRF, tanpa regenerasi sesi | Rute POST ber-@csrf, blokir sesama admin, session()->regenerate() |
| Kanal WebSocket | Semua guru bisa mendengarkan chat tugas kelas lain | Dibatasi khusus untuk guru pembuat tugas dan siswa di kelas terkait |
| Regression Testing | Belum ada automated test untuk skenario IDOR | 411 baris test di IdorTest.php yang dieksekusi di GitHub Actions CI |
Pelajaran Utama untuk Aplikasi Laravel Multi-Role
- Jangan Pernah Mengandalkan Tampilan UI Saja: Menyembunyikan tombol "Edit" atau "Hapus" pada Blade sama sekali tidak menjamin keamanan. Selama pengguna bisa mengirim request HTTP atau aksi Livewire, backend wajib memvalidasi otorisasi secara independen.
- Kunci State Sensitif di Livewire dengan
#[Locked]: Setiap ID internal yang mengontrol proses penyimpanan wajib di-annotate dengan#[Locked]agar tidak dapat dimanipulasi melalui request payload. - Kombinasikan Policies dengan Scoped Queries:
Gunakan Policies untuk otorisasi di layer kontroler/komponen (
$this->authorize(...)) dan gunakan Eloquent scopes (whereHas('examination', ...)) untuk isolasi di layer database. - Anggap Lingkungan Browser Siswa Tidak Tepercaya:
Kunci jawaban dan pembahasan tidak boleh pernah dikirim ke frontend selama ujian aktif berlangsung. Gunakan properti
$hiddenpada Eloquent. - Jadikan Celah Keamanan Menjadi Automated Test: Setiap kali menemukan celah, buatlah test yang mereproduksinya terlebih dahulu. Dengan begitu, sistem memiliki jaminan abadi bahwa celah tersebut tidak akan terbuka kembali di masa depan.
๐ Tautan & Referensi:

