MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Laravel E-Learning

1

Part 1: Pengenalan & Mengapa Saya Upgrade

2

Part 2: Setup Laravel 12 + Filament

3

Part 3: Desain Skema Database

4

Part 4: Autentikasi & Role User

5

Part 5: Membangun Sistem Ujian

6

Part 6: Fitur Real-Time

7

Part 7: Strategi Testing

8

Part 8: Deployment

9

Part 9: Perbaikan Issue Komunitas & CI/CD

10

Part 10: Penguatan Keamanan & Celah IDOR

Artikel ini tersedia dalam Bahasa Inggris

๐Ÿ‡ฌ๐Ÿ‡ง Read in English
๐Ÿ‡ฎ๐Ÿ‡ฉ Bahasa Indonesia๐Ÿ“š Rebuild Laravel E-Learning

Laravel E-Learning Part 10: Menutup Celah IDOR & Penguatan Keamanan Sistem (PR #18)

Part 10 dari seri Laravel E-Learning: membedah celah keamanan Insecure Direct Object Reference (IDOR), menerapkan kebijakan otorisasi Laravel Policies, mengunci state Livewire dengan #[Locked], menyembunyikan kunci jawaban ujian, dan mengamankan fitur impersonasi admin pada PR #18.

Muhammad Fari MadyanPenulis

14 menit baca

ยท

3 Oktober 2026


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:

  1. Kebocoran Materi Antar Kelas: Jika siswa mengubah parameter URL dari /student/materials/12 menjadi /student/materials/13, mereka dapat membuka materi milik kelas lain.
  2. Bypass Pratinjau Draft: Siswa bahkan bisa membaca materi yang masih dalam bentuk draf sebelum tanggal publikasi resminya.
  3. Pengumpulan Tugas Tak Sah: Siswa dapat memanggil fungsi Assignments::openSubmit($assignmentId) untuk tugas kelas lain, atau mengumpulkan tugas yang sudah melewati tenggat waktu padahal opsi allow_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 / VektorSebelum PR #18Sesudah PR #18
Manajemen Soal UjianQuestion::findOrFail($id) mengizinkan edit/hapus lintas guruScoped query ke teacher_id + pengecekan QuestionPolicy
Monitor & Cetak UjianSemua user ber-role guru bisa membuka ujian guru mana punExaminationPolicy@monitor membatasi hanya untuk pembuat ujian
Koreksi Hasil UjianGuru bisa mengoreksi attempt dari ujian guru lainExaminationPolicy@grade memverifikasi kepemilikan ujian dan attempt
Materi PembelajaranSiswa bisa melihat materi draf & materi milik kelas lainLearningMaterialPolicy memverifikasi is_published dan classroom_id
Pengumpulan TugasSiswa bisa mengumpulkan ke kelas lain atau sesudah batas waktuAssignmentPolicy@submit memvalidasi kelas, status, dan tenggat
Jawaban CBTSiswa bisa menyimpan jawaban soal dari ujian lainVerifikasi $examination->questions()->where('id', $questionId)->exists()
Serialisasi Model SoalKunci jawaban bocor di JSON snapshot browserDitekan via Question::$hidden = ['correct_answer', 'explanation']
Impersonasi AdminRute GET, tanpa CSRF, tanpa regenerasi sesiRute POST ber-@csrf, blokir sesama admin, session()->regenerate()
Kanal WebSocketSemua guru bisa mendengarkan chat tugas kelas lainDibatasi khusus untuk guru pembuat tugas dan siswa di kelas terkait
Regression TestingBelum ada automated test untuk skenario IDOR411 baris test di IdorTest.php yang dieksekusi di GitHub Actions CI

Pelajaran Utama untuk Aplikasi Laravel Multi-Role

  1. 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.
  2. 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.
  3. 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.
  4. Anggap Lingkungan Browser Siswa Tidak Tepercaya: Kunci jawaban dan pembahasan tidak boleh pernah dikirim ke frontend selama ujian aktif berlangsung. Gunakan properti $hidden pada Eloquent.
  5. 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:

Lanjut Membaca

Previous article thumbnail

โ† Artikel Sebelumnya

Laravel E-Learning Part 9: Resolving Community Issues, Exam Flow Hardening & CI/CD Pipeline

Artikel Selanjutnya โ†’

React Native Expo Event Management Part 1: Pengenalan & Arsitektur

Next article thumbnail