Keamanan Pencarian
Kotak pencarian yang dapat menjangkau seluruh resource dalam sebuah panel merupakan salah satu titik yang paling mudah menyebabkan kebocoran record pada admin panel. Halaman ini menjelaskan secara tepat jaminan keamanan yang diberikan pencarian global, hal-hal yang tidak dijamin, serta keputusan keamanan yang tetap menjadi tanggung jawab Anda. Baca bagian ini sebelum memasukkan resource dengan data sensitif ke dalam palet pencarian.
Contoh minimal yang dapat digunakan
Jaminan keamanan dapat diuji. Berikut contoh pengujian untuk membuktikannya:
<?php
declare(strict_types=1);
use App\Models\User;
it('refuses a panel the user may not enter', function (): void {
$this->actingAs(User::factory()->create()) // bukan admin
->getJson('/admin/search?q=Lovelace')
->assertForbidden();
});
it('sends a guest to login rather than searching', function (): void {
$this->get('/admin/search?q=Lovelace')->assertRedirect(route('login'));
});
it('never leaves a model or a hash in the payload', function (): void {
$encoded = $this->actingAs(User::factory()->admin()->create())
->getJson('/admin/search?q=Lovelace')
->content();
expect($encoded)->not->toContain('App\\Models')
->and($encoded)->not->toContain('$2y$');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Empat lapisan pembatasan
Request pencarian melewati empat lapisan pembatasan yang independen, sesuai urutan berikut.
| Lapisan | Diterapkan oleh | Dampak |
|---|---|---|
| Transport | middleware stack milik panel | guest diarahkan ke login, pengguna yang ditolak mendapat 403, dan challenge two-factor yang belum selesai tidak pernah mencapai controller |
| Resource | Resource::canViewAny() | resource yang sama sekali tidak boleh dilihat pengguna dilewati sebelum query dibuat |
| Row | Resource::query() | tenant scope, modifikasi query per panel, dan scope milik resource tetap diterapkan |
| Payload | GlobalSearchResult | satu hasil hanya berisi judul, URL, dan map string — tidak ada model, query, maupun closure |
Transport
Route pencarian didaftarkan di dalam group milik panel, sehingga route mewarisi seluruh middleware stack panel:
$this->router->get('search', PanelSearchController::class)->name('search');Artinya route akan melewati web, seluruh middleware yang ditambahkan auth() (auth, serta verified kecuali Anda memberikan false), kemudian ResolvePanel, RequireTwoFactor, RequireEmailCode, dan ResolveTenant untuk panel yang menggunakan tenancy. ResolvePanel memanggil abort_unless($panel->isAccessibleTo($request->user()), 403): pengguna yang sudah login tetapi tidak diizinkan masuk ke panel akan menerima 403, bukan redirect, dan tidak akan pernah menerima hasil pencarian.
Tidak ada mekanisme perlindungan khusus hanya untuk endpoint pencarian — justru itu desainnya. Endpoint menggunakan pintu keamanan yang sama dengan seluruh route panel lainnya.
Resource
if (! $resource::isGloballySearchable() || ! $resource::canViewAny()) {
continue;
}2
3
Kedua pemeriksaan dilakukan sebelum query apa pun dibangun. Resource yang ditolak tidak menghasilkan biaya query dan tidak membocorkan apa pun — bahkan tidak menimbulkan perbedaan timing dibandingkan query yang berjalan lalu menghasilkan nol row.
canViewAny() berjalan melalui Resource::authorize() dan PandaPanel\Support\PolicyGate, yaitu satu titik tempat ability panel diperiksa. Jika strictAuthorization() aktif, model tanpa policy — atau policy tanpa viewAny() dan tanpa before() — akan memicu PandaPanel\Exceptions\PanelAuthorizationException sehingga request gagal secara eksplisit, bukan diam-diam dianggap sebagai deny yang normal.
Row
globalSearchQuery() dimulai dari Resource::query(), dan query() adalah tempat scope diterapkan:
public static function query(): Builder
{
$query = static::isNested()
? static::parentRelation()->getQuery()->with(static::$with)
: static::getModel()::query()->with(static::$with);
$query = static::applyTenantScope($query);
return static::configurationIn(panel())?->applyQuery($query) ?? $query;
}2
3
4
5
6
7
8
9
10
Dengan demikian, palet mempersempit data dengan cara yang sama seperti halaman daftar. Resource yang sudah membatasi query-nya tidak dapat diperluas kembali melalui pencarian:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Resources\Resource;
final class ScopedUserResource extends Resource
{
/**
* Palet membaca melalui `globalSearchQuery()`, yang dimulai dari `query()`.
*
* @var list<string>
*/
protected static array $globalSearchAttributes = ['name', 'email'];
public static function query(): Builder
{
return parent::query()->where('is_admin', false);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Admin tidak dapat ditemukan melalui resource tersebut, apa pun term yang digunakan.
Payload
final readonly class GlobalSearchResult
{
public function __construct(
public string $title,
public string $url,
public array $details = [],
) {}
}2
3
4
5
6
7
8
Model sudah tidak ada ketika data mulai diserialisasi. Frontend hanya menerima judul, URL yang dibuat server, dan detail yang dipilih resource. Karena itu, atribut yang tidak secara eksplisit dimasukkan oleh resource tidak dapat muncul dalam payload, dan frontend tidak perlu mengambil keputusan mengenai identitas record atau lokasinya.
Term pencarian tidak pernah menjadi bagian struktur SQL
$globalSearchAttributes berfungsi sebagai whitelist. Term pencarian hanya digunakan sebagai bound value:
$like = '%'.$this->escapeLike($term).'%';
$query->orWhere($attribute, 'like', $like);2
3
Nama kolom berasal dari class resource; request hanya menyumbangkan parameter q. Tidak ada parameter searchable, tidak ada daftar kolom pada query string, dan request tidak memiliki cara untuk menentukan nama tabel.
Controller memvalidasi satu-satunya input yang diterima:
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:255'],
]);2
3
Input lebih dari 255 karakter menghasilkan response 422. Term yang setelah trim memiliki panjang kurang dari dua karakter mengembalikan [] tanpa menyentuh database. Selain menghemat query, ini juga mencegah probe satu karakter menjadi cara murah untuk melakukan enumerasi tabel.
Term di-escape untuk LIKE: karakter %, _, dan \ diperlakukan sebagai karakter literal sebelum framework menambahkan % di awal dan akhir pattern. Namun, pencocokan tetap berupa substring. Artinya, jika Anda memasukkan kolom yang menyimpan data rahasia ke daftar pencarian, data rahasia tersebut tetap dapat dicari. Whitelist bukan sulap.
Hal yang tidak diperiksa oleh pencarian global
Authorization per record. canView($record) tidak pernah dipanggil. Keanggotaan sebuah record di dalam hasil ditentukan oleh globalSearchQuery(), bukan policy per record. Konsekuensinya nyata: jika policy memberikan viewAny secara luas tetapi view secara terbatas, pengguna dapat melihat judul dan detail sebuah record pada palet, mengkliknya, lalu menerima 403 dari halaman tujuan.
Ini merupakan trade-off yang disengaja. Menjalankan policy check untuk setiap row akan mengubah setiap input keyboard menjadi loop authorization per record. Karena itu, scope query, bukan policy per record, menjadi kontrol akses utama pada tahap pencarian. Ada dua cara umum untuk menyelaraskannya:
use Illuminate\Database\Eloquent\Builder;
// Nyatakan aturan yang sama dengan policy dalam bentuk SQL.
public static function globalSearchQuery(): Builder
{
return static::query()->where('team_id', auth()->user()?->team_id);
}2
3
4
5
6
7
// Atau jangan masukkan resource ke palet jika aturannya tidak dapat
// diekspresikan dengan aman sebagai query.
protected static array $globalSearchAttributes = [];2
3
Isi detail hasil. Output globalSearchResultDetails() ditampilkan kepada setiap pengguna yang dapat mencari resource tersebut. Detail tidak difilter lagi oleh policy per record. Jangan memasukkan token, internal note, hash, atau informasi kontak privat milik pengguna lain.
Rate limiting. Framework tidak menambahkan rate limiter secara default. Setiap input yang lolos debounce dapat menghasilkan query, sementara client terotomasi dapat mengirim request jauh lebih cepat daripada manusia mengetik. Tambahkan limiter ke middleware panel jika diperlukan:
$panel->middleware(['web', 'throttle:120,1']);Perhatikan bahwa middleware() mengganti base stack, bukan menambahkan ke stack yang sudah ada. Karena itu, deklarasikan kembali web.
Escaping term untuk LIKE. Pencarian bawaan meng-escape %, _, dan \. Jika Anda mengganti logika pencarian dengan query buatan sendiri, pertahankan karakteristik keamanan tersebut.
Checklist review keamanan
| Pertanyaan | Periksa di mana |
|---|---|
Apakah setiap resource yang dapat dicari memiliki policy dengan viewAny()? | Resource::canViewAny(); aktifkan strictAuthorization() untuk menemukan celah |
Apakah viewAny dapat bernilai true ketika view bernilai false? | Jika ya, persempit globalSearchQuery() agar sesuai dengan policy |
Apakah query() milik resource tetap membawa tenant scope? | $tenantRelationship dan Scope resource pada tenancy |
Apakah globalSearchQuery() yang di-override masih dimulai dari query()? | Query yang dimulai dari Model::query() melewati tenant scope dan scope per panel |
| Apakah ada kolom sensitif yang ikut dicari? | $globalSearchAttributes — substring matching dapat menjadikan kolom sebagai sumber kebocoran informasi |
| Apakah detail hasil mengandung data sensitif? | globalSearchResultDetails() |
| Apakah panel membutuhkan rate limit? | Panel::middleware() |
Hal yang perlu diperhatikan
- Nested resource di dalam palet berpotensi menjadi sumber bug keamanan.
query()miliknya membutuhkan parent record yang terikat, sedangkan request pencarian tidak memilikinya. Meng-overrideglobalSearchQuery()agar dimulai langsung dari model dapat secara diam-diam melewati tenant scope. Lihat URL hasil pencarian. strictAuthorization()mengubah policy yang hilang menjadi kegagalan request, bukan hasil kosong. Exception-nya adalahRuntimeException, sehingga request dapat menghasilkan 500. Ini memang mekanisme fail loudly yang disengaja, tetapi artinya mengaktifkan strict mode dapat terlebih dahulu memutus pencarian sebelum Anda menemukan masalah serupa pada halaman biasa.- Request gagal ditampilkan seperti "Nothing found." Palet menelan response non-2xx. Akibatnya, 403 setelah perubahan permission dan pencarian yang memang tidak memiliki hasil dapat terlihat sama bagi pengguna. Baca log dan Network tab, bukan hanya dialog.
- Menonaktifkan pencarian tidak menghapus route. Route tetap mengembalikan
{"groups": []}untuk panel tersebut karenaGlobalSearch::for()memeriksahasGlobalSearch()lebih dahulu. - Menyembunyikan resource dari navigasi tidak menyembunyikannya dari pencarian.
$shouldRegisterNavigationdan$globalSearchAttributesadalah dua deklarasi yang tidak saling terkait. - Detail dan judul di-escape oleh Vue, sehingga nama record yang mengandung markup tidak dapat menyuntikkan HTML ke dalam palet. Jangan menganggap ini sebagai sanitasi untuk aspek lain dari hasil pencarian; tidak ada field lain yang otomatis disanitasi dengan cara yang sama.