Mengamankan Panel
Panel adalah permukaan publik yang besar: setiap parameter table dapat mencapai query builder, setiap action endpoint menerima record key dari payload, dan setiap export menulis file yang diakses berdasarkan nama. Halaman ini membahas pekerjaan untuk menutup permukaan tersebut pada satu Panel — strict authorization, policy yang menjawab seluruh ability yang ditanyakan Panel, penyempitan query yang berlaku pada setiap route, tenant scoping, upload dan action endpoint, serta file download — kemudian dilanjutkan dengan test yang membuktikan semuanya. Baca halaman ini sebelum Panel yang membawa data nyata dipublikasikan, dan baca kembali setiap kali resource, endpoint, atau job baru ditambahkan.
Tidak ada security system terpisah di sini. Setiap control tetap berupa Laravel policy biasa, middleware biasa, atau whitelist yang sudah dideklarasikan schema. Pekerjaannya adalah memastikan setiap control tersebut benar-benar berada di tempat yang semestinya.
Contoh minimal yang berfungsi
Empat baris konfigurasi dan sebuah policy sudah menutup celah paling umum:
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
return $panel
->path('admin')
->auth() // a guest is redirected
->requireTwoFactor() // and a signed-in user without a second factor is held
->strictAuthorization() // a missing policy throws instead of denying
->canAccess(static fn (?Authenticatable $user): bool
=> $user instanceof User && $user->is_admin); // 403, not a redirect2
3
4
5
6
7
8
9
10
final class ProductPolicy
{
public function viewAny(User $user): bool { return $user->is_admin; }
public function view(User $user, Product $product): bool { return $user->is_admin; }
public function create(User $user): bool { return $user->is_admin; }
public function update(User $user, Product $product): bool { return $user->is_admin; }
public function delete(User $user, Product $product): bool { return $user->is_admin; }
public function deleteAny(User $user): bool { return $user->is_admin; }
}2
3
4
5
6
7
8
9
Seluruh bagian berikutnya membahas sisa permukaan yang perlu diamankan.
Pintu masuk
Ada dua rule independen, dan keduanya harus sama-sama mengizinkan.
public function auth(bool $verified = true): self
public function canAccess(Closure $callback): self // Closure(?Authenticatable): bool
public function isAccessibleTo(?Authenticatable $user): bool2
3
auth() menambahkan auth dan verified ke auth middleware Panel — method ini mengakumulasi, bukan mengganti. canAccess() adalah predicate untuk Panel ini. Authenticated user yang gagal predicate mendapat 403, bukan redirect: mereka sudah berhasil login, sehingga redirect justru dapat memberikan kesan bahwa ada tempat lain yang seharusnya dapat dimasuki.
Separuh aturan lainnya adalah rule tentang account, diletakkan pada user model agar berlaku pada seluruh Panel sekaligus dan tidak terlupa ketika Panel keempat ditambahkan:
use PandaPanel\Contracts\PanelUser;
use PandaPanel\Core\Panel;
final class User extends Authenticatable implements PanelUser
{
public function canAccessPanel(Panel $panel): bool
{
return $this->hasVerifiedEmail() && $this->suspended_at === null;
}
}2
3
4
5
6
7
8
9
10
isAccessibleTo() memeriksa contract terlebih dahulu lalu closure milik Panel. Panel yang mengizinkan tidak dapat membatalkan penolakan user model, dan user model yang permisif tidak dapat melonggarkan predicate Panel. User model yang tidak mengimplementasikan keduanya tidak mendapat penolakan tambahan dari layer ini.
PandaPanel\Http\Middleware\ResolvePanel menegakkan rule tersebut pada semua route yang diregistrasikan Panel — page, resource page, action endpoint, search, upload, export. Tidak ada satu page pun yang dapat terlupa diamankan selama route tersebut memang berada di route group Panel.
Tiga keputusan tambahan juga sebaiknya dibuat di pintu masuk:
$panel
->requireTwoFactor() // held at the security page until a second factor exists
->domain('admin.example.com') // keeps the panel off every other host
->middleware(['web', VerifyIpAllowlist::class]); // REPLACES the base stack — 'web' must be listed2
3
4
requireTwoFactor() menggunakan middleware, bukan per-page check, karena tujuan utamanya adalah melindungi page yang belum sempat dicapai user. Jika check harus ditambahkan satu-per-satu, page berikutnya dapat terlupa. Passkey dihitung sebagai second factor, sehingga user yang sudah memakai hardware key tidak dipaksa menurunkan keamanan ke kode. Security page dan account settings yang diperlukan untuk memenuhi requirement tersebut dikecualikan agar user tetap memiliki tempat untuk mengaktifkannya.
Stack yang dibangun registrar, secara berurutan:
{panel middleware, 'web' by default}
{panel auth middleware, from auth()}
ResolvePanel:{id} → binds the panel, then abort_unless(isAccessibleTo, 403)
RequireTwoFactor:{id} → no-op unless requireTwoFactor()
RequireEmailCode:{id} → for an account that turned emailed codes on
ResolveTenant:{id} → only when the panel declared tenant()2
3
4
5
6
Strict authorization
public function strictAuthorization(bool $strictAuthorization = true): self
public function hasStrictAuthorization(): bool // false by default2
Secara default, policy yang tidak ada terlihat sama seperti policy yang secara eksplisit menjawab tidak. Dengan strict authorization aktif, model yang tidak memiliki policy terdaftar — atau policy yang tidak memiliki ability yang sedang diperiksa — melempar PandaPanel\Exceptions\PanelAuthorizationException yang menyebut model dan ability, bukan diam-diam melakukan denial.
Seluruh ability yang ditanyakan Panel melewati satu tempat, PandaPanel\Support\PolicyGate:
public static function allows(string $ability, Model|string $subject, array $arguments = []): boolResource::authorize() dan RelationManager::authorize() keduanya mendelegasikan ke sini dan tidak memanggil Gate::allows() secara langsung. Dengan demikian jaminan strict mode juga tetap berlaku untuk relation abilities, yang memang tidak memiliki method can* pada Resource sebagai tempat lain untuk menggantungkan pemeriksaan.
Policy yang mendefinisikan before() dikecualikan, karena method tersebut memang dapat menjawab seluruh ability.
Aktifkan strict authorization setidaknya selama development. Default-nya off karena pada production mode ini dapat mengubah denial 403 menjadi exception 500. Namun restoreAny() yang terlupa dan terlihat seperti "user memang tidak boleh restore" adalah bug konfigurasi yang jauh lebih mudah ditemukan jika failure-nya eksplisit.
Policy harus menjawab seluruh ability yang ditanyakan Panel
Resource menggunakan ability berikut:
| Method Resource | Ability | Subject |
|---|---|---|
canViewAny() | viewAny | class model |
canView($record) | view | record |
canCreate() | create | class model |
canEdit($record) | update | record |
canDelete($record) | delete | record |
canDeleteAny() | deleteAny | class model |
canRestore($record) | restore | record |
canRestoreAny() | restoreAny | class model |
canForceDelete($record) | forceDelete | record |
canForceDeleteAny() | forceDeleteAny | class model |
Relation manager menanyakan ability berbeda untuk keanggotaan relation:
| Method | Ability | Policy |
|---|---|---|
canAttach($owner) | attachAny | policy owner |
canDetach($owner, $record) | detach | policy owner, record sebagai argument kedua |
canAssociate($owner) | associateAny | policy owner |
canDissociate($owner, $record) | dissociate | policy owner, record sebagai argument kedua |
Ability *Any diperiksa sebelum bulk action mulai memiliki record tertentu. Setelah itu setiap selected record tetap di-authorize satu per satu sebelum satu pun write dilakukan. Jika selection berisi satu record yang dilarang, operasi tidak boleh mengubah record lain terlebih dahulu lalu baru gagal pada record yang dilarang.
Jika Anda override method can*(), tetap lewati jalur authorization bawaan atau panggil parent:: agar strict mode tidak ter-bypass:
final class ProductResource extends Resource
{
public static function canCreate(): bool
{
return parent::canCreate() && Product::query()->count() < 10_000;
}
}2
3
4
5
6
7
Pada level internal, seluruh authorization akhirnya menuju:
protected static function authorize(string $ability, Model|string $argument): bool
{
return PolicyGate::allows($ability, $argument);
}2
3
4
Menyembunyikan navigation item atau button hanyalah presentation convenience. Request manual tetap harus ditolak kembali oleh policy/server check.
Query adalah scope
Resource::query() merupakan satu funnel untuk seluruh pembacaan record. List page, findRecord() / findRecords(), action endpoint, global search, TableQuery, dan exporter semuanya berawal dari query yang sama. Record yang dikeluarkan query menjadi tidak dapat dijangkau dari seluruh jalur tersebut.
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
// parent::query() applies $with, the tenant scope, and the panel's own
// per-panel narrowing. An override that skips it drops all three, and
// nothing about the resulting screen says so.
return parent::query()->where('team_id', auth()->user()?->team_id);
}2
3
4
5
6
7
8
9
Record yang tidak termasuk scope menghasilkan 404, bukan 403. Dari perspektif Resource, record tersebut memang tidak ada. 403 justru akan mengonfirmasi bahwa key itu valid tetapi user tidak boleh membukanya.
Penyempitan juga dapat didefinisikan per Panel tanpa membuat subclass Resource:
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(UserResource::class)
->modifyQueryUsing(static fn (Builder $query) => $query->whereKey(auth()->id())),
]);2
3
4
5
6
Tenant scoping
Panel tenant mendeklarasikan cara satu tenant di-resolve:
use App\Models\Workspace;
use Illuminate\Http\Request;
$panel->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->where('slug', $request->route('workspace'))
->first(),
);2
3
4
5
6
7
8
9
Resource yang datanya dimiliki tenant menyebut relationship-nya:
final class DocumentResource extends Resource
{
protected static ?string $tenantRelationship = 'workspace';
}2
3
4
ResolveTenant berjalan setelah user diketahui dan sebelum controller menjalankan query. Tenant yang tidak dapat di-resolve menghasilkan 404. Jika HasPanelTenants::canAccessPanelTenant() menjawab false, response adalah 403. Baru setelah kedua langkah itu tenant di-bind sebagai current tenant.
User model yang tidak mengimplementasikan HasPanelTenants tidak dianggap memiliki semua tenant; pada tenant Panel user tersebut tidak memiliki tenant mana pun dan setiap tenant request ditolak.
Resource yang menyatakan $tenantRelationship tetapi tidak memiliki current tenant juga tidak boleh diam-diam menjadi unscoped. Tenancy::require() melempar ketika tidak ada binding:
public static function require(): Model // PanelRegistrationException when nothing is boundPada console atau queued job, masuklah ke tenant secara eksplisit:
use PandaPanel\Tenancy\Tenancy;
Tenancy::for($workspace, static fn () => DocumentResource::query()->each(/* … */));2
3
canAccessPanelTenant() harus melakukan pemeriksaan independen. Jangan mengimplementasikannya sebagai getPanelTenants()->contains(...) lalu menganggap query switcher sebagai authorization boundary; method tersebut adalah decision point keamanan tersendiri.
Checklist lengkap: Tenancy Security Checklist. Recipe lengkap: Tenant Panel.
Actions
Action name berasal dari payload, tetapi name tersebut selalu di-resolve terhadap schema yang dideklarasikan dan registry Panel saat ini.
public function authorize(Closure $callback): static // Closure(?Model): bool
public function authorizeEachUsing(Closure $callback): static // Closure(Model): bool — every record, before any write
public function visible(Closure $callback): static // Closure(?Model): bool — drawing only
public function requiresConfirmation(...): static2
3
4
visible() adalah presentation. authorize() adalah security boundary di server.
Action::make('publish')
->authorize(static fn (?Model $record): bool => $record !== null
&& ProductResource::canEdit($record))
->authorizeEachUsing(static fn (Model $record): bool => ProductResource::canEdit($record))
->action(static fn (Model $record) => $record->forceFill(['is_published' => true])->save());2
3
4
5
Framework memiliki whitelist action terpisah untuk enam endpoint berbeda:
| Endpoint | Apa yang dapat di-resolve |
|---|---|
| record action | hanya recordActions() pada table |
| bulk action | hanya bulkActions() pada table |
| table action | header/toolbar/empty-state actions, tanpa record |
| infolist action | hanya action yang dideklarasikan Resource::infolist() |
| cell action | hanya editable table column; server mengecek update dan disabledUsing() kembali |
| reorder | hanya column reorder yang dideklarasikan table |
Record key harus scalar. Array pada field yang seharusnya satu key menghasilkan 422, bukan diteruskan ke Eloquent find() dengan semantik collection yang berbeda.
Editable columns
ToggleColumn, TextInputColumn, SelectColumn, dan CheckboxColumn melakukan write melalui /actions/cell. Endpoint tersebut mengulang pemeriksaan authorization dan disabled state di server.
Privilege-bearing field memerlukan guard yang lebih ketat daripada sekadar policy update umum:
ToggleColumn::make('is_admin')
->disabledUsing(static function (Model $record): bool {
$actor = auth()->user();
// The policy lets a user edit their own record, which is right for a
// display name and catastrophic for a privilege flag.
return ! ($actor instanceof User && $actor->is_admin)
|| $actor->is($record);
});2
3
4
5
6
7
8
9
Method penting pada editable column:
public function rules(array $rules): static
public function disabledUsing(Closure $callback): static
public function writeTo(string $attribute): static
public function updateUsing(Closure $callback): static2
3
4
Input Table adalah whitelist, bukan jalan keluar
Setiap parameter table memang pada akhirnya dapat memengaruhi query builder, tetapi nama yang tidak dideklarasikan schema tidak dianggap sebagai column/filter/group yang valid.
| Input | Pembatas |
|---|---|
sort | hanya column yang sortable() |
direction | value selain desc dinormalisasi ke ascending sebelum builder digunakan |
perPage | harus ada dalam opsi per-page schema, jika tidak kembali ke default |
page | value negatif atau absurd tetap dijawab secara aman; tidak diberikan kebebasan pada builder |
search | % dan _ diperlakukan sebagai karakter literal, bukan wildcard untuk dump table |
filters[…] | masing-masing sanitize() filter; SelectFilter hanya menerima option key yang dideklarasikan |
group, columns[] | hanya group dan column yang dideklarasikan |
| query-builder rule | hanya constraint dan operator yang memang ditawarkan filter |
Semua ini diperoleh dengan mendeklarasikan schema. Keputusan yang tetap menjadi tanggung jawab Anda adalah apa yang dideklarasikan. Column yang diberi searchable() berarti data pada column itu memang dapat dicari oleh user. Constraint QueryBuilderFilter terhadap suatu column berarti user dapat melakukan probing range pada column tersebut.
Form hanya menulis field yang dideklarasikan
Create atau edit hanya menulis field yang dideklarasikan schema. Extra key dalam request body dibuang, bukan disimpan. Ini menjadi lock kedua di atas $fillable milik model.
Kedua lock tetap penting. Privilege flag harus tetap dikeluarkan dari $fillable meskipun tidak ada form yang mendeklarasikannya:
/**
* `is_admin` is deliberately absent. Registration and profile updates both
* fill from request input, and a privilege flag that is mass-assignable is a
* privilege anyone can grant themselves by adding a field to a form post.
*
* @var list<string>
*/
protected $fillable = ['name', 'email', 'password'];2
3
4
5
6
7
8
Relation form mengikuti rule yang sama dan memisahkan kedua bagian: field milik related record tetap menggunakan nama aslinya, sedangkan field pivot berada di bawah pivot.. Hanya field yang dideklarasikan pivotForm() yang dapat mencapai join row.
Upload
Upload endpoint adalah tempat request berpotensi memilih file disimpan di mana. Framework sengaja tidak memberi pilihan tersebut kepada request.
POST /{panel}/uploads
body: field, file
query: resource, and one of — page=create | page=edit&record=… | relation&operation=… | action&scope=…2
3
Request hanya menyebut resource dan field, tidak pernah disk atau directory. Keduanya berasal dari deklarasi field yang di-resolve dari schema:
FileUpload::make('image')
->image() // sets the common image mime types
->disk('public')
->directory('products') // normalized: '..' stripped, slashes trimmed
->maxSize(2048) // kilobytes
->acceptedTypes(['image/png', 'image/webp']);2
3
4
5
6
Semua batas tersebut ditegakkan dua kali: pertama oleh upload endpoint terhadap file nyata, lalu diperiksa lagi ketika form disubmit. Ini memang dua request berbeda dan baru request kedua yang menempelkan path ke record. Submitted path di luar directory yang dideklarasikan ditolak, bukan disimpan.
Permission upload adalah permission yang sama dengan permission untuk submit form tempat field tersebut berada, tidak lebih lemah:
| Context pada query string | Schema yang dibangun | Ability |
|---|---|---|
page=create | create form Resource | create |
page=edit + record | edit form Resource | update pada record tersebut |
relation + operation | relation form | ability relation sesuai operation |
action + scope | action form | ability action tersebut |
Context dibangun server dan dikirim melalui query string; client hanya menambahkan nama field dan file. Ability untuk membaca Resource tidak cukup — upload menulis file ke disk yang dipilih aplikasi, dan izin melihat daftar tidak sama dengan izin menulis ke disk tersebut.
File yang keluar dari Panel
Export adalah salinan record yang user diizinkan melihat. Failure report adalah salinan data yang user mencoba import. Keduanya justru merupakan jenis file yang menarik untuk ditebak namanya, dan keduanya di-address oleh filename dari request.
public static function disk(): string { return 'local'; } // never 'public'
public static function directory(): string { return 'panel-exports'; }2
File disimpan di bawah owner key, dan download endpoint membangun segmen owner dari authenticated user:
{disk}/panel-exports/{user key}/{file}
{disk}/panel-imports/{user key}/{file}2
// PanelExportController and PanelImportController, both:
abort_if(
$file === '' || str_contains($file, '/') || str_contains($file, '\\') || str_contains($file, '..'),
404,
);2
3
4
5
Request hanya membawa nama, bukan path. Path traversal tidak memiliki directory yang dapat dikendalikan caller. User yang menebak filename milik user lain tetap mencari nama tersebut di directory miliknya sendiri dan tidak menemukan apa pun.
Jangan memindahkan export ke public disk. Public disk menaruh salinan record pada URL yang dapat ditebak siapa pun, sedangkan alasan download endpoint ada justru untuk mengulangi authorization boundary.
CSV formula escaping aktif secara default (Exporter::escapesFormulas()). Cell yang diawali =, +, -, atau @ dinetralkan dengan leading apostrophe. Attacker-nya bisa siapa pun yang mampu menulis text field, sedangkan victim-nya administrator yang membuka export — bentuk ancaman yang sangat relevan untuk admin panel. Nonaktifkan hanya jika file dibaca oleh program lain yang tidak pernah mengevaluasi formula.
Search
Global search bersifat opt-in per Resource dan melewati empat penyempitan independen:
| Layer | Ditegakkan oleh |
|---|---|
| Transport | middleware stack Panel — pintu yang sama dengan route lain |
| Resource | Resource::canViewAny(), diperiksa sebelum query Resource dijalankan |
| Row | Resource::query() — tenant scope, per-panel narrowing, dan scope Resource sendiri |
| Payload | satu result hanya berisi title, URL, dan map string — tidak ada model, query, atau closure |
Keputusan aplikasi adalah Resource mana yang dibuat searchable dan data apa yang dimasukkan globalSearchResultDetails() ke command palette. Detail line akan terlihat oleh siapa pun yang memang boleh melihat Resource tersebut.
Outbound requests
Resource dengan integrations aktif memiliki screen tempat administrator dapat mengonfigurasi outbound HTTP request yang dijalankan server ketika record berubah. Karena destination ditulis melalui form alih-alih hardcoded di source, fitur tersebut secara desain merupakan permukaan Server-Side Request Forgery (SSRF).
Sebuah URL harus melewati dua gate:
// config/panda-panel.php
'integrations' => [
// Str::is() patterns. Empty, so nothing is reachable until a destination
// is added here: deny by default, and adding one is a deploy rather than
// a form submission.
'allowed_hosts' => [
// 'api.example.com',
// '*.partner.io',
],
// Refuses unresolved hosts and any host resolving into private, loopback
// or link-local ranges — 169.254.169.254 above all, the unauthenticated
// cloud metadata endpoint that hands out IAM credentials. Checked when an
// integration is saved and again immediately before each request, because
// a name approved last week can resolve elsewhere today. IPv4-mapped IPv6
// literals are normalized back to IPv4 before these checks run.
'block_private_networks' => true,
],2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Biarkan gate kedua tetap aktif. Itulah yang membuat pelonggaran allowlist host masih dapat bertahan aman. Delivery tidak mengikuti redirect, sehingga allowed host tidak dapat mengarahkan server ke private address setelah validation.
Delivery history menyimpan body dalam bentuk truncated dan tidak pernah menyimpan header. Header dapat berisi API key; menyimpan seluruh header akan mengubah log menjadi credential store yang tidak pernah dimaksudkan untuk dibuat.
Notifications
Semua notification route di-scope ke notification milik authenticated user berdasarkan konstruksi: query selalu dimulai dari $request->user(), sehingga tidak ada ID dari request yang dapat menjangkau notification milik user lain. Scope tersebut adalah authorization, sehingga endpoint notification tidak memerlukan policy tersendiri.
Verifikasi
# The middleware is actually on the routes.
php artisan route:list --path=admin
# Nothing in the panel reads records outside Resource::query().
grep -rn "::query()" app/Panels/Admin --include=*.php
# Every privilege flag is out of $fillable.
grep -rn "fillable" app/Models --include=*.php
# The manifest matches the code that is deployed.
php artisan panel:cache2
3
4
5
6
7
8
9
10
11
Checklist yang sebaiknya diselesaikan setidaknya satu kali:
Test
Tuliskan penolakan sebagai hal yang tidak boleh terjadi. Tiga bentuk test berikut mencakup sebagian besar permukaan:
<?php
declare(strict_types=1);
use App\Models\Product;
use App\Models\User;
use App\Panels\Admin\Resources\Products\Exports\ProductExporter;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
$this->admin = User::factory()->admin()->create();
$this->member = User::factory()->create();
$this->product = Product::factory()->create();
});
/*
* Privilege escalation — past the policy without pressing a button.
*/
it('refuses a member every route to a record they may not touch', function (): void {
$this->actingAs($this->member);
$this->get('/admin/products')->assertForbidden();
$this->get("/admin/products/{$this->product->id}")->assertForbidden();
$this->put("/admin/products/{$this->product->id}/edit", ['name' => 'x'])->assertForbidden();
$this->post('/admin/actions/record', [
'resource' => 'products', 'action' => 'delete', 'record' => $this->product->id,
])->assertForbidden();
$this->post('/admin/actions/cell', [
'resource' => 'products', 'column' => 'is_published',
'record' => $this->product->id, 'value' => true,
])->assertForbidden();
expect($this->product->fresh()->is_published)->toBeFalse();
});
it('is a 404, not a 403, in a panel that does not register the resource', function (): void {
// Nothing there to be refused: the route was never registered.
$this->actingAs($this->admin)->get('/app/products')->assertNotFound();
});
/*
* Hostile table input — a name the schema never declared does not exist.
*/
function appliedSortFor(string $query): ?string
{
return test()->get('/admin/products?'.$query)
->viewData('page')['props']['state']['sort'] ?? null;
}
it('treats a SQL fragment in the sort parameter as a name, not as SQL', function (): void {
$this->actingAs($this->admin);
foreach (['name; drop table products', 'name) or 1=1--', '1'] as $fragment) {
expect(appliedSortFor('sort='.urlencode($fragment)))
->toBeNull("sort={$fragment} was accepted as a column");
}
// And the one that is sortable is honoured, so the above is a whitelist
// rather than a parameter that never works.
expect(appliedSortFor('sort=name'))->toBe('name');
// The table is still there, which a successful injection would not leave.
expect(Product::query()->count())->toBe(1);
});
/*
* File access — an export belongs to exactly one user.
*/
it('refuses traversal and another user\'s directory alike', function (): void {
Storage::fake('local');
Storage::disk('local')->put('secret.csv', 'ROOT SECRET');
Storage::disk('local')->put(
ProductExporter::directory().'/'.$this->member->id.'/theirs.csv',
'sku,name',
);
$this->actingAs($this->admin);
foreach (['../secret.csv', '..%2Fsecret.csv', '/etc/passwd', 'subdir/secret.csv', '.', '..'] as $attempt) {
$response = $this->get('/admin/exports/'.$attempt.'?exporter='.urlencode(ProductExporter::class));
expect($response->getStatusCode())->toBeIn([403, 404]);
}
// The name is right; the directory is not this user's.
$this->get('/admin/exports/theirs.csv?exporter='.urlencode(ProductExporter::class))
->assertNotFound();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
Framework menyatakan security property yang sama melalui sepuluh file di tests/Feature/Panel/Negative/:
| File | Membuktikan bahwa |
|---|---|
HostileTableInputTest | sort, filter, group, search, atau page parameter yang tidak dideklarasikan schema diabaikan, bukan "di-escape" |
PrivilegeEscalationTest | guessed URL, hand-written POST, atau swapped ID tidak dapat melewati policy |
ScopeBypassTest | record yang dikeluarkan Resource::query() tidak dapat dijangkau melalui endpoint apa pun yang menerima key |
SchemaEscapeTest | create atau edit hanya menulis data yang dideklarasikan form |
MalformedInputTest | input dengan shape salah tetap dijawab dengan benar dan tidak membuat framework crash |
FileAndDataAccessTest | export, import report, atau notification hanya dimiliki tepat satu user |
SpreadsheetFormulaTest | cell CSV tidak dapat berubah menjadi formula saat file dibuka spreadsheet |
SchemaMistakeTest | schema yang tidak dapat berarti seperti yang dideklarasikan gagal saat build dan menyebut nama yang salah |
UnreachableDeclarationTest | deklarasi yang menunjuk sesuatu yang tidak dapat menjawab gagal dengan pesan yang menyebut declaration target |
SilentAbsenceTest | kondisi "resource saya hilang" memiliki penyebab yang dapat dibaca developer |
Tiga guard dari suite tersebut pernah diverifikasi dengan cara menghapus guard-nya lalu memastikan test benar-benar gagal. Itulah standar yang layak diterapkan pada security test: test yang tetap lolos ketika guard dihapus hanyalah dekorasi.
php artisan test --compact tests/Feature/Panel/NegativeHal yang perlu diperhatikan
middleware()danauthMiddleware()mengganti;auth()melakukan merge. Panel yang memanggilmiddleware([SomeCheck::class])tanpawebbaru saja membuang session dan CSRF. Panel yang memanggilauthMiddleware([])memang membuat dirinya public.strictAuthorization()dapat mengubah 403 menjadi 500. Ini trade-off yang sangat masuk akal saat development dan keputusan yang perlu dipertimbangkan di production.- Override
can*()yang memanggilGate::allows()secara langsung secara diam-diam mengeluarkan ability tersebut dari strict mode. - Override
query()yang lupaparent::query()sekaligus membuang eager load, tenant scope, dan per-panel narrowing, sementara screen tetap dapat terlihat normal. canAccess()berjalan pada setiap request yang masuk Panel. Query di dalamnya berarti query pada setiap page load. Method ini juga ditanyakan di luar request oleh switcher danfirstAccessibleTo(), sehingga jangan bergantung padarequest()->route().- Route yang diregistrasikan manual tidak otomatis berada di group Panel. Route tersebut tidak membawa middleware stack Panel. Jika memang perlu route manual, sebutkan middleware class dan Panel ID:
ResolvePanel::class.':'.$panel->getId(). - Central Panel pada skema tenant subdomain membutuhkan
domain(). Tanpanyaadmin.example.testdapat teridentifikasi sebagai tenant bernamaadmin. visible()bukan authorization. Action yang disembunyikan tetapi tidak di-authorize masih dapat dijalankan melalui hand-written POST.- Ability
*Anyadalah method terpisah. Policy dengandeletetetapi tanpadeleteAnymenolak seluruh bulk delete; pada strict mode kondisi tersebut melempar exception. - Cached manifest berarti discovery tidak berjalan. Setelah menambahkan Resource di production, jalankan ulang
php artisan panel:cache. Resource yang tidak ada di manifest tidak memiliki route maupun policy check karena dari perspektif Panel resource tersebut memang tidak ada.
Lihat juga
- Panel Access Rules
- Authorization
- Resource Authorization, Action Authorization
- Page Authorization, Widget Authorization
- Relation Policies
- Tenancy Security Checklist
- Search Security
- Authentication Security, Two-Factor
- File Uploads
- Negative Security Tests, Testing Authorization
- Debugging a 403
- Production Checklist
- Tenant Panel, Import and Export
- Reporting a Vulnerability