Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ package-lock.json
/playwright-report/
.env.e2e
/template_ai/
AGENTS.md
29 changes: 29 additions & 0 deletions app/Http/Controllers/DataPresisiPapanController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\View\View;

class DataPresisiPapanController extends Controller
{
/**
* Display detail data presisi papan dengan filter opsional.
*
* @param Request $request HTTP request instance
* @return View View detail data dengan DataTables
*/
public function detailData(Request $request): View
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 📝 Code Quality: Manual Input Validation

Kategori: Architecture
Masalah: Validasi input menggunakan isset() manual. Best practice Laravel adalah menggunakan FormRequest untuk validasi yang lebih robust dan reusable.
Kode: if (isset($filter['column']) && isset($filter['nilai']) && isset($filter['tipe']) && isset($filter['judul'])) {

Fix:

// Buat FormRequest baru: app/Http/Requests/DataPresisiPapanRequest.php
class DataPresisiPapanRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'filter.column' => 'nullable|string|max:255',
            'filter.nilai' => 'nullable|string|max:255',
            'filter.tipe' => 'nullable|string|max:255',
            'filter.judul' => 'nullable|string|max:255',
        ];
    }
}

// Update controller:
public function detailData(DataPresisiPapanRequest $request, ?array $filter = null): View
{
    $validated = $request->validated();
    $filter = $validated['filter'] ?? null;
    // ... rest of code
}

$colomn = '';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 🔒 Security: Input Validation Lemah - Potensi XSS via Filter Parameters

Masalah:
Controller menggunakan isset() untuk validasi input, kemudian langsung menggunakan htmlspecialchars() dan strip_tags() pada output. Namun, validasi input tidak memeriksa tipe data atau format yang diharapkan. Parameter filter['tipe'] dan filter['nilai'] bisa dimanipulasi untuk inject payload berbahaya yang mungkin lolos dari sanitasi sederhana.

Kode:

if (isset($filter['tipe'])) {
    $colomn = $filter['tipe'];
    $judul = htmlspecialchars(strip_tags($filter['judul'] ?? ''), ENT_QUOTES, 'UTF-8');
}

if (isset($filter['nilai'])) {
    $colomn = $filter['nilai'];
    $judul = htmlspecialchars(strip_tags($filter['judul'] ?? ''), ENT_QUOTES, 'UTF-8');
}

Risiko:

  • Attacker bisa mengirim payload kompleks yang memanfaatkan edge case dari strip_tags() dan htmlspecialchars()
  • Tidak ada whitelist validation untuk filter['tipe'] dan filter['nilai'] - bisa berisi nilai arbitrary
  • Jika nilai ini digunakan di query atau logic lain (tidak terlihat di diff tapi mungkin di future), bisa menyebabkan injection

PoC (Chrome Console):

// Jalankan di Chrome DevTools Console (F12 → Console)
// Pastikan sudah login ke aplikasi di tab yang sama

// Test 1: XSS payload via filter parameter
const xssPayload = {
    tipe: '<script>alert("XSS")</script>',
    nilai: '"><img src=x onerror=alert("XSS2")>',
    judul: '<svg/onload=alert("XSS3")>'
};

const url = new URL('/data-presisi/papan/detail_data', window.location.origin);
Object.keys(xssPayload).forEach(key => {
    url.searchParams.append(`filter[${key}]`, xssPayload[key]);
});

// Navigate ke URL dengan payload
window.location.href = url.toString();

// Test 2: Cek apakah payload muncul di page source
// Setelah page load, buka DevTools dan cek:
console.log('Check page source for unescaped payload');
console.log(document.documentElement.innerHTML.includes('<script>'));

Fix:

use App\Http\Requests\DataPresisiPapanRequest;

// Buat FormRequest baru: app/Http/Requests/DataPresisiPapanRequest.php
class DataPresisiPapanRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'filter.tipe' => 'nullable|string|in:status_kepemilikan,jenis_lantai,jenis_dinding,kondisi_dinding,jenis_atap,kondisi_atap,sumber_penerangan,daya_listrik,energi_memasak,fasilitas_bab,sumber_air_minum,cara_memperoleh_air,kualitas_air',
            'filter.nilai' => 'nullable|string|max:255',
            'filter.judul' => 'nullable|string|max:255',
        ];
    }
}

// Update controller method signature:
public function detailData(DataPresisiPapanRequest $request)
{
    $filter = $request->validated()['filter'] ?? [];
    $column = null;
    $judul = '';

    if (isset($filter['tipe'])) {
        $column = $filter['tipe'];
        $judul = $filter['judul'] ?? '';
    }

    if (isset($filter['nilai'])) {
        $column = $filter['nilai'];
        $judul = $filter['judul'] ?? '';
    }

    return view('data_pokok.data_presisi.papan.detail_data', compact('column', 'judul'));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 📝 Code Quality: Typo Variable Name

Kategori: PHP Quality
Masalah: Variable name typo $colomn seharusnya $column (terjadi di 3 tempat: line 12, 18, 21)
Kode: $colomn = $filter['column'] ?? null;

Fix:

$column = $filter['column'] ?? null;
// Dan update semua reference:
$column = htmlspecialchars(strip_tags($column), ENT_QUOTES, 'UTF-8');
'column' => $column,

$title = 'Data Presisi Papan - ' . $request->input('judul');
$title = htmlspecialchars(strip_tags($title), ENT_QUOTES, 'UTF-8');
$filter = $request->input('filter');

if (isset($filter['tipe'], $filter['nilai']) && $filter['tipe'] && $filter['nilai']) {
$colomn = $filter['tipe'] . ':' . $filter['nilai'];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 🐛 Bug: Undefined Array Key Access

Kode: $judul = htmlspecialchars(strip_tags($request->judul)) . ' - ' . htmlspecialchars(strip_tags($request->filter[$colomn]));

Skenario: Ketika isset($request->judul) bernilai true tapi $request->filter[$colomn] tidak ada (misalnya user hanya mengirim parameter judul tanpa filter[tipe]), maka akan terjadi "Undefined array key" warning di PHP 8.0+ atau "Undefined index" notice di PHP 7.x. Ini bisa terjadi jika:

  1. Request dimanipulasi manual via URL/Postman
  2. Filter tipe dihapus dari request tapi judul tetap ada
  3. Race condition saat form submission

Dampak:

  • PHP 8.0+: Warning "Undefined array key" muncul di log
  • PHP 7.x: Notice "Undefined index"
  • Halaman bisa crash atau menampilkan error 500 jika error reporting strict
  • User experience buruk dengan error message yang tidak informatif

Fix:

$judul = 'Detail Data Presisi Papan';
if (isset($request->judul)) {
    $colomn = $request->filter['tipe'];
    // Tambahkan validasi key exists sebelum akses
    $nilaiFilter = isset($request->filter[$colomn]) ? htmlspecialchars(strip_tags($request->filter[$colomn])) : 'Tidak Diketahui';
    $judul = htmlspecialchars(strip_tags($request->judul)) . ' - ' . $nilaiFilter;
}

Atau lebih baik dengan null coalescing:

$judul = 'Detail Data Presisi Papan';
if (isset($request->judul) && isset($request->filter['tipe'])) {
    $colomn = $request->filter['tipe'];
    $nilaiFilter = htmlspecialchars(strip_tags($request->filter[$colomn] ?? 'Tidak Diketahui'));
    $judul = htmlspecialchars(strip_tags($request->judul)) . ' - ' . $nilaiFilter;
}


return view('data_pokok.data_presisi.papan.detail_data', compact('title', 'colomn'));
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/StatistikPapanController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class StatistikPapanController extends Controller
public function index()
{
return view('presisi.statistik.papan', [
'detailLink' => url(''),
'detailLink' => url('data-presisi/papan/detail_data'),
'judul' => 'Papan'
]);
}
Expand Down
184 changes: 184 additions & 0 deletions resources/views/data_pokok/data_presisi/papan/detail_data.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
@extends('layouts.index')

@section('title', $title)

@section('content_header')
<h1>{{ html_entity_decode($title) }}</h1>
@stop

@section('content')
@include('partials.breadcrumbs')

<div class="row">
<div class="col-lg-12">
<div class="card card-outline card-primary">
<div class="card-header">
<div class="row">
<x-filter-tahun :selectedYear="request('tahun')" />
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped" id="detail-papan">
<thead>
<tr>
<th>NO</th>
<th>NIK</th>
<th>NOMOR KK</th>
<th>NAMA</th>
<th>STATUS KEPEMILIKAN</th>
<th>LUAS LANTAI (M²)</th>
<th>JENIS LANTAI</th>
<th>JENIS DINDING</th>
<th>SUMBER AIR MINUM</th>
<th>SUMBER PENERANGAN</th>
<th>DAYA TERPASANG</th>
<th>DAYA TERPASANG 2</th>
<th>DAYA TERPASANG 3</th>
<th>TANGGAL PENGISIAN</th>
<th>STATUS PENGISIAN</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection

@section('js')
<script nonce="{{ csp_nonce() }}">
document.addEventListener("DOMContentLoaded", function(event) {
const headers = @include('layouts.components.header_bearer_api_gabungan');
var url = new URL("{{ config('app.databaseGabunganUrl').'/api/v1/data-presisi/papan' }}");
var papan = $('#detail-papan').DataTable({
processing: true,
serverSide: true,
autoWidth: false,
ordering: true,
searchPanes: {
viewTotal: false,
columns: [0]
},
ajax: {
url: url.href,
headers: headers,
method: 'get',
data: function(row) {
const data = {
"page[size]": row.length,
"page[number]": (row.start / row.length) + 1,
"filter[search]": row.search.value,
"filter[tahun]": $('#filter-tahun').val(),
"filter[colomn]": '{{ $colomn }}',
"sort": (row.order[0]?.dir === "asc" ? "" : "-") + row.columns[row.order[0]?.column]?.name,
};

return data;
},
dataSrc: function(json) {
json.recordsTotal = json.meta.pagination.total;
json.recordsFiltered = json.meta.pagination.total;
return json.data;
},
},
columnDefs: [{
targets: '_all',
className: 'text-nowrap',
}, {
targets: [0, 1, 2, 3, 4, 5],
orderable: false,
searchable: false,
}],
columns: [{
data: null,
orderable: false,
render: function(data, type, row, meta) {
return meta.row + meta.settings._iDisplayStart + 1;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] 🔒 Security: Missing CSRF Token pada AJAX GET Request

Masalah:
DataTables AJAX request ke external API tidak menyertakan CSRF token. Meskipun ini GET request (yang secara default tidak memerlukan CSRF protection di Laravel), jika API endpoint berubah menjadi POST/PUT/DELETE di masa depan atau jika ada state-changing operation, ini bisa menjadi masalah.

Kode:

ajax: {
    url: `${apiUrl}/api/v1/data-presisi/papan`,
    type: 'GET',
    headers: header, // hanya bearer token, tidak ada X-CSRF-TOKEN
    data: function(d) {
        d.tahun = selectedYear;
        if (column) {
            d[`filter[${column}]`] = judul;
        }
    },
    // ... rest of config
}

Risiko:

  • Jika endpoint API berubah menjadi POST/PUT/DELETE, request akan gagal atau rentan CSRF
  • Best practice: selalu sertakan CSRF token untuk consistency
  • Jika ada side-effect di GET endpoint (bad practice tapi mungkin terjadi), bisa dieksploitasi

PoC (Chrome Console):

// Jalankan di Chrome DevTools Console (F12 → Console)
// Simulasi CSRF attack jika endpoint berubah ke POST

// Buat form tersembunyi di attacker site
const attackForm = document.createElement('form');
attackForm.method = 'POST';
attackForm.action = 'https://target-site.com/api/v1/data-presisi/papan';
attackForm.style.display = 'none';

// Tambah field
const tahunField = document.createElement('input');
tahunField.name = 'tahun';
tahunField.value = '2024';
attackForm.appendChild(tahunField);

// Submit form (akan gagal karena CORS, tapi konsep CSRF tetap valid)
document.body.appendChild(attackForm);
attackForm.submit();

console.log('⚠️ CSRF attack simulated - jika endpoint POST tanpa CSRF protection, ini akan berhasil');

Fix:

// Tambahkan CSRF token di header
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

ajax: {
    url: `${apiUrl}/api/v1/data-presisi/papan`,
    type: 'GET',
    headers: {
        ...header,
        'X-CSRF-TOKEN': csrfToken // Tambahkan CSRF token
    },
    data: function(d) {
        d.tahun = selectedYear;
        if (column) {
            d[`filter[${column}]`] = judul;
        }
    },
    // ... rest of config
}

// Pastikan meta tag CSRF ada di layout:
// <meta name="csrf-token" content="{{ csrf_token() }}">

},
{
data: 'attributes.nik',
orderable: false,
},
{
data: 'attributes.no_kk',
orderable: false,
},
{
data: 'attributes.nama',
orderable: false,
},
{
data: "attributes.kd_stat_bangunan_tinggal",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.luas_lantai",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_jenis_lantai_terluas",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_jenis_dinding",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_sumber_air_minum",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_sumber_penerangan_utama",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_daya_terpasang",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_daya_terpasang2",
defaultContent: 'N/A',
orderable: false,
},
{
data: "attributes.kd_daya_terpasang3",
defaultContent: 'N/A',
orderable: false,
},
{
data: 'attributes.tanggal_pengisian',
orderable: false
},
{
data: 'attributes.status_pengisian',
orderable: false
},
],
});
papan.on('draw.dt', function() {
var PageInfo = $('#detail-papan').DataTable().page.info();
papan.column(0, {
page: 'current'
}).nodes().each(function(cell, i) {
cell.innerHTML = i + 1 + PageInfo.start;
});
});

// Event listener for year filter change
$('#filter-tahun').on('change', function() {
papan.ajax.reload();
});
});
</script>
@endsection
Loading
Loading