Negative Security Test
Sepuluh file di bawah tests/Feature/Panel/Negative/ menyatakan dalam bentuk hal-hal yang tidak boleh terjadi berbagai jaminan yang di bagian lain test suite biasanya ditulis secara positif. Panel memiliki public surface yang besar — setiap parameter tabel dapat mencapai query builder, setiap action endpoint menerima record key dari payload, setiap export menulis file yang dialamatkan berdasarkan nama — dan jaminan pada surface tersebut baru benar-benar menjadi jaminan jika ada test yang gagal saat pengaman rusak. Gunakan halaman ini ketika menambahkan resource, endpoint, atau mekanisme download file baru.
Contoh minimal yang berfungsi
Pola yang paling sering digunakan: kirim parameter hostile, lalu pastikan result set tetap sama seperti yang akan diterima request normal.
<?php
declare(strict_types=1);
use App\Models\User;
/**
* @return list<string>
*/
function rowNamesFor(string $query): array
{
$page = test()->get('/admin/users?'.$query)->viewData('page');
return collect($page['props']['rows'] ?? [])
->pluck('cells.name.value')
->filter()
->values()
->all();
}
it('ignores a filter name the schema never declared', function (): void {
User::factory()->count(3)->create();
$this->actingAs(User::factory()->create(['is_admin' => true]));
expect(rowNamesFor('filters[password]=secret'))->toHaveCount(4);
expect(User::query()->count())->toBe(4);
});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
Dua assertion tersebut sama-sama penting: row tidak berubah dan tabel masih tetap ada. Injection yang benar-benar berhasil akan membuat assertion kedua gagal.
Sepuluh file
| File | Menyatakan bahwa |
|---|---|
HostileTableInputTest | parameter sort, filter, group, search, atau page yang tidak pernah dideklarasikan schema diabaikan, bukan sekadar di-escape |
PrivilegeEscalationTest | URL hasil tebakan, POST buatan sendiri, atau id yang ditukar tidak dapat melewati policy |
ScopeBypassTest | record yang dikeluarkan Resource::query() tidak dapat dijangkau melalui endpoint mana pun yang menerima key |
SchemaEscapeTest | create atau edit hanya menulis apa yang dideklarasikan form |
MalformedInputTest | input dengan bentuk salah tetap dijawab sebagai request invalid, bukan menyebabkan crash |
FileAndDataAccessTest | export, import report, atau notification hanya dapat diakses oleh satu pengguna yang berhak |
SpreadsheetFormulaTest | cell CSV tidak dapat berubah menjadi formula saat dibuka di spreadsheet |
SchemaMistakeTest | schema yang secara logis tidak mungkin benar ditolak saat build dan error menyebut nama yang salah |
UnreachableDeclarationTest | deklarasi yang menunjuk sesuatu yang tidak dapat merespons gagal dengan pesan yang menyebut target tersebut |
SilentAbsenceTest | kondisi "resource saya hilang" memiliki penyebab yang dapat dibaca developer |
Tiga pengaman di suite ini sudah diverifikasi dengan cara menghapus pengamannya lalu memastikan test benar-benar gagal. Itulah standar yang layak digunakan untuk security test: test yang tetap lulus ketika guard dihapus hanyalah dekorasi.
Hostile input pada tabel
Setiap parameter tabel berasal dari URL dan pada akhirnya dapat memengaruhi query builder. Aturannya sederhana: nama yang tidak dideklarasikan schema tidak dianggap ada — bukan "di-escape", bukan "diberi quote", tetapi ditolak atau diabaikan sejak awal.
Jumlah row tidak dapat membuktikan apakah sort diterapkan karena tiga row tetap tiga row apa pun urutannya. Karena itu assertion membaca applied state yang dikembalikan tabel:
function appliedSortFor(string $query): ?string
{
$page = test()->get('/admin/users?'.$query)->viewData('page');
return $page['props']['state']['sort'] ?? null;
}
it('treats a SQL fragment in the sort parameter as a name, not as SQL', function (): void {
$fragments = [
'name; drop table users',
'name) or 1=1--',
'(select count(*) from users)',
'name`,`email',
'users.name',
'1',
];
foreach ($fragments as $fragment) {
expect(appliedSortFor('sort='.urlencode($fragment)))
->toBeNull("sort={$fragment} was accepted as a column");
expect(rowNamesFor('sort='.urlencode($fragment)))
->toHaveCount(3, "sort={$fragment} changed the result set");
}
expect(User::query()->count())->toBe(3);
});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
Whitelist harus dibuktikan benar-benar berfungsi sebagai whitelist, bukan sekadar parameter yang kebetulan selalu diabaikan:
// `avatar` and `accountAge` are real columns and neither is ->sortable().
expect(appliedSortFor('sort=avatar'))->toBeNull();
expect(appliedSortFor('sort=accountAge'))->toBeNull();
// And the one that is sortable is honoured.
expect(appliedSortFor('sort=name'))->toBe('name');2
3
4
5
6
Surface lainnya memiliki test sendiri-sendiri: nilai direction yang tidak dikenal jatuh kembali ke ascending alih-alih masuk ke builder; perPage dibatasi untuk nilai 999999, -1, 0, all, dan 10; drop table users; page negatif atau absurd tetap dijawab 200; nilai ternary di luar tiga nilai yang diterima tidak mengubah query; rule query-builder yang menyebut constraint atau operator yang tidak pernah ditawarkan ditolak; % dan _ pada search term diperlakukan sebagai karakter literal, bukan wildcard untuk membaca seluruh tabel; serta group atau columns[] yang menunjuk nama tidak dikenal diabaikan.
Test terakhir mengirim semuanya sekaligus lalu mendengarkan query database:
use Illuminate\Support\Facades\DB;
it('issues no query that the database refuses', function (): void {
$failures = [];
DB::listen(function ($query) use (&$failures): void {
if (str_contains(strtolower($query->sql), 'drop table')) {
$failures[] = $query->sql;
}
});
test()->get('/admin/users?'.http_build_query([
'sort' => 'name; drop table users',
'direction' => 'desc; drop table users',
'search' => "'; drop table users--",
'group' => 'name; drop table users',
'perPage' => '10; drop table users',
'filters' => ['is_admin' => '1; drop table users'],
]))->assertOk();
expect($failures)->toBeEmpty();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Parameter yang benar-benar mencapai builder sebagai SQL akan terlihat di listener sebagai fragment query, bukan sebagai binding.
Privilege escalation
Semua kasus berikut adalah cara pengguna dapat mencoba melewati policy tanpa pernah menekan tombol yang ditawarkan UI.
// Reading somebody else's record.
$this->actingAs($member)->get("/admin/users/{$other->id}")->assertForbidden();
$this->actingAs($member)->get("/admin/users/{$other->id}/edit")->assertForbidden();
$this->actingAs($member)->get('/admin/users')->assertForbidden();
// Writing it, three ways.
$this->actingAs($member)->put("/admin/users/{$other->id}/edit", [...])->assertForbidden();
$this->actingAs($member)->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => $other->id,
])->assertForbidden();
$this->actingAs($member)->post('/admin/actions/cell', [
'resource' => 'users', 'column' => 'name', 'record' => $other->id, 'value' => 'x',
])->assertForbidden();2
3
4
5
6
7
8
9
10
11
12
13
Bagian yang lebih menarik adalah privilege flag. Policy contoh mengizinkan member mengedit record miliknya sendiri — benar untuk display name tetapi berbahaya untuk is_admin. Karena itu terdapat test untuk tiga jalur yang dapat mencapai flag tersebut, dan salah satunya menguji guard yang tidak diuji jalur lain:
it('does not let an administrator toggle the flag on their own account', function (): void {
// An administrator is admitted to the panel and the policy lets them edit
// themselves, so the column's `disabledUsing()` is the only thing left.
$this->actingAs($this->admin)->post('/admin/actions/cell', [
'resource' => 'users',
'column' => 'is_admin',
'record' => $this->admin->id,
'value' => false,
]);
expect($this->admin->fresh()->is_admin)->toBeTrue();
});2
3
4
5
6
7
8
9
10
11
12
Kemudian ada kasus lintas panel, yang harus menghasilkan 404, bukan 403. Resource tidak terdaftar di panel tersebut, sehingga tidak ada resource yang dapat "menolak" request:
$this->actingAs($this->admin)->get('/app/users')->assertNotFound();
$this->actingAs($this->admin)->post('/app/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => $other->id,
])->assertNotFound();2
3
4
5
Guest juga diuji pada setiap endpoint: route HTML menghasilkan redirect, sedangkan endpoint JSON menghasilkan 401.
Scope bypass
Resource yang mempersempit query() — berdasarkan tenant, team, status "not archived", atau scope lainnya — bergantung pada semua jalur menuju record menggunakan narrowing yang sama. Satu route yang tidak melakukannya akan membatalkan seluruh tujuan scope: list menampilkan apa yang boleh Anda lihat, tetapi id yang ditebak menampilkan apa yang tidak boleh Anda lihat.
Setiap endpoint yang menerima record key harus menjawab 404 karena record di luar scope dianggap tidak ada bagi resource tersebut:
$this->get("/scope-host/scoped-users/{$outside->id}")->assertNotFound();
$this->get("/scope-host/scoped-users/{$outside->id}/edit")->assertNotFound();
$this->put("/scope-host/scoped-users/{$outside->id}/edit", [...])->assertNotFound();
$this->post('/scope-host/actions/record', [
'resource' => 'scoped-users', 'action' => 'delete', 'record' => $outside->id,
])->assertNotFound();
$this->post('/scope-host/actions/cell', [
'resource' => 'scoped-users', 'column' => 'name', 'record' => $outside->id, 'value' => 'x',
])->assertNotFound();2
3
4
5
6
7
8
9
10
11
Global search adalah jalur kedua untuk mencapai record berdasarkan nama, sehingga harus tunduk pada narrowing yang sama:
$results = json_encode($this->getJson('/scope-host/search?q=Scope')->json());
expect($results)->toContain('In Scope')->and($results)->not->toContain('Out Of Scope');2
3
Tutup file test dengan control yang membuktikan route sebenarnya bekerja. Tanpa control ini, route yang rusak total akan membuat semua assertion penolakan di atas tetap lulus:
it('reaches a record inside the scope, so the refusals above are the scope and not a broken route', function (): void {
$this->get("/scope-host/scoped-users/{$inside->id}")->assertOk();
});2
3
Schema escape
Form panel adalah whitelist: schema menentukan nama field, dan apa pun yang masuk dengan nama lain bukan field. Sifat ini adalah batas antara endpoint create dan seluruh column yang tersedia pada model.
it('persists no attribute the create form never declared', function (): void {
$this->post('/admin/users/create', [
'name' => 'Mallory',
'email' => 'mallory@example.test',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'remember_token' => 'stolen-token',
'two_factor_secret' => 'stolen-secret',
'two_factor_confirmed_at' => now()->toDateTimeString(),
'id' => 9999,
]);
$user = User::query()->where('email', 'mallory@example.test')->firstOrFail();
expect($user->remember_token)->not->toBe('stolen-token')
->and($user->two_factor_secret)->toBeNull()
->and($user->two_factor_confirmed_at)->toBeNull()
->and($user->id)->not->toBe(9999);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Pilih column yang benar-benar penting — password hash, remember token, two-factor secret, verification timestamp. Semua column tersebut ada di tabel database tetapi tidak ada di form.
Endpoint editable cell adalah form kecil tersendiri dan harus diperlakukan sama. Write ke column yang bukan editable menghasilkan 400, sedangkan nilai yang gagal terhadap rule milik column menghasilkan 422.
// `email` is a TextColumn, not a TextInputColumn.
$this->post('/admin/actions/cell', [
'resource' => 'users', 'column' => 'email', 'record' => $target->id,
'value' => 'hijacked@example.test',
])->assertStatus(400);
// `name` declares required and maxLength(255).
$this->postJson('/admin/actions/cell', [
'resource' => 'users', 'column' => 'name', 'record' => $target->id, 'value' => '',
])->assertStatus(422);2
3
4
5
6
7
8
9
10
Malformed input
Ini tidak selalu merupakan serangan; malformed input adalah kondisi biasa bagi public endpoint. Yang penting adalah kelas responsnya: 4xx berarti endpoint memahami request tetapi menolaknya; 500 berarti endpoint crash, dapat membocorkan stack trace, memenuhi log, dan bisa dipicu ulang oleh siapa pun.
/**
* Every status that means "the endpoint decided", as opposed to "the endpoint
* broke". A redirect counts.
*
* @return list<int>
*/
function answeredStatuses(): array
{
return [200, 201, 204, 302, 400, 401, 403, 404, 405, 409, 422, 429];
}
it('answers rather than breaks on a malformed record action payload', function (): void {
$payloads = [
[],
['resource' => 'users'],
['resource' => ['users'], 'action' => 'delete', 'record' => 1],
['resource' => 'users', 'action' => 'delete', 'record' => null],
['resource' => 'users', 'action' => 'delete', 'record' => ['a' => 'b']],
['resource' => 'users', 'action' => 'delete', 'record' => str_repeat('9', 500)],
['resource' => str_repeat('a', 5000), 'action' => 'delete', 'record' => 1],
['resource' => "users\0", 'action' => 'delete', 'record' => 1],
];
foreach ($payloads as $index => $payload) {
$status = $this->post('/admin/actions/record', $payload)->getStatusCode();
expect($status)->toBeIn(answeredStatuses(), "record payload #{$index} produced {$status}");
}
});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
Pola yang sama digunakan untuk endpoint bulk, cell, options, search, notification, dan upload; untuk record key yang sebenarnya bukan key (abc, 0, -1, 1e400, ../../etc/passwd, %00, empat ratus angka sembilan); serta untuk HTTP verb yang tidak ditawarkan route.
Upload memiliki dua test yang memeriksa content, bukan sekadar shape. Salah satunya tidak dapat ditulis menggunakan UploadedFile::fake():
it('refuses a PHP script wearing a png extension', function (): void {
Storage::fake('public');
// A *real* uploaded file: the fake reports its type from the file name,
// so it would answer image/png for anything called .png and this test
// would pass without testing anything.
$path = tempnam(sys_get_temp_dir(), 'panda').'.png';
file_put_contents($path, "<?php echo 'hi'; ?>\n");
$file = new UploadedFile($path, 'payload.png', null, null, test: true);
expect($file->getMimeType())->not->toBe('image/png');
$this->postJson(uploadUrl(), [
'resource' => 'form-fixtures',
'field' => 'attachment',
'file' => $file,
])->assertStatus(422);
expect(Storage::disk('public')->allFiles())->toBeEmpty();
@unlink($path);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Akses file dan data lintas pengguna
Export adalah salinan dari semua record yang dapat dilihat exporter, ditulis ke disk lalu tetap berada di sana. Kedua sisi desainnya perlu diuji: path tidak boleh diselundupkan melalui nama file, dan nama file yang valid tidak boleh dapat mencapai directory milik pengguna lain.
it('refuses every shape of traversal in an export file name', function (): void {
// Both rungs a traversal would climb to. Without these the directories do
// not exist, every attempt misses for the wrong reason, and the test
// passes with the guard deleted.
Storage::disk('local')->put('secret.csv', 'ROOT SECRET');
Storage::disk('local')->put(UserExporter::directory().'/secret.csv', 'PARENT SECRET');
$attempts = [
'../secret.csv', '../../secret.csv', '..%2Fsecret.csv', '%2e%2e%2fsecret.csv',
'....//secret.csv', '..%5Csecret.csv', '/etc/passwd', 'subdir/secret.csv',
'.', '..',
];
foreach ($attempts as $attempt) {
$response = $this->get('/admin/exports/'.$attempt.'?exporter='.urlencode(UserExporter::class));
expect($response->getStatusCode())->toBeIn([403, 404]);
}
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Menanam decoy file adalah bagian yang mudah dilewatkan tetapi justru membuat test benar-benar berarti. . dan .. ikut diuji karena keduanya mencapai controller sebagai nama, bukan dinormalisasi router; tanpa guard, kasus tersebut dapat berakhir 500.
Selanjutnya uji akses lintas pengguna dan sertakan control yang membuktikan endpoint memang bekerja:
Storage::disk('local')->put(UserExporter::directory().'/'.$other->id.'/report.csv', 'name,email');
// The name is right; the directory it lives in is not this user's.
$this->actingAs($this->admin)
->get('/admin/exports/report.csv?exporter='.urlencode(UserExporter::class))
->assertNotFound();2
3
4
5
6
Parameter exporter menunjuk class, sehingga juga diuji sendiri: User::class, facade, class yang tidak ada, dan string kosong semuanya harus menjawab 404.
Notification dibatasi melalui query, bukan policy, sehingga perlu perlakuan yang sama. Satu pengguna tidak boleh dapat menampilkan, membaca, atau menghapus notifikasi pengguna lain:
$this->actingAs($this->admin)
->postJson('/admin/notifications/read', ['id' => $notification->getKey()])
->assertOk();
// Matched nothing rather than 403'd — the same outcome, one fewer leak.
expect($other->unreadNotifications()->count())->toBe(1);2
3
4
5
6
Formula spreadsheet
Cell CSV yang diawali =, +, -, atau @ dianggap formula oleh Excel, LibreOffice, dan Google Sheets, lalu dievaluasi ketika file dibuka. Penyerang cukup memiliki kemampuan menulis text field; korban adalah administrator yang membuka file export. Kategori masalah ini dikenal sebagai CWE-1236.
use PandaPanel\Support\Spreadsheet\Csv;
it('neutralizes every character a spreadsheet reads as a formula', function (): void {
foreach (['=', '+', '-', '@', "\t", "\r"] as $prefix) {
expect(Csv::neutralize($prefix.'SUM(A1)'))->toStartWith("'");
}
});
it('leaves ordinary text exactly as it was', function (): void {
foreach (['Apollo', '2026-08-15', 'a=b', '', '0'] as $value) {
expect(Csv::neutralize($value))->toBe($value);
}
});2
3
4
5
6
7
8
9
10
11
12
13
Quoting hanya menyelesaikan parsing file dan tidak mengubah arti cell setelah diparse — padahal justru arti cell itulah sumber masalah. Karena itu test membaca cell kembali melalui fgetcsv() lalu memastikan apostrophe tetap ada. Format xlsx tidak membutuhkan perlakuan tersebut dan memiliki test yang menjelaskan alasannya: setiap cell ditulis sebagai t="inlineStr" dan writer tidak pernah membuat elemen <f>.
Kesalahan schema dan kegagalan yang diam-diam
Tiga file terakhir membahas kegagalan yang dulunya tidak terlihat, bukan serangan langsung. Mereka tetap berada di directory ini karena bentuk test-nya sama: sesuatu yang tidak boleh terjadi dinyatakan secara eksplisit sebagai failure.
SchemaMistakeTest melakukan assertion terhadap pesan error sekaligus exception type. Penolakan yang tidak menyebut nama mana yang salah tidak membantu developer yang sedang membaca resource dengan puluhan column:
expect(fn () => TableSchema::make()->columns([
TextColumn::make('name'),
TextColumn::make('email'),
TextColumn::make('name'),
]))->toThrow(PanelSchemaException::class, 'more than one column named [name]');
expect(fn () => Action::make('send invoice'))
->toThrow(PanelSchemaException::class, 'try [send-invoice]');
expect(fn () => TableSchema::make()
->columns([TextColumn::make('name'), TextColumn::make('created_at')])
->defaultSort('createdAt')
->toArray())->toThrow(PanelSchemaException::class, 'It has: name, created_at');2
3
4
5
6
7
8
9
10
11
12
13
Di samping setiap refusal terdapat test untuk kondisi yang tidak boleh ikut ditolak — relationship group yang menggunakan nama sama dengan owner, action dengan nama sama tetapi berada pada set berbeda, filter terhadap column yang tidak ditampilkan tabel, atau column span yang sekadar berada di luar range. Tanpa test positif tersebut, membuat pemeriksaan semakin ketat dapat terlihat sebagai improvement padahal merusak kasus valid.
SilentAbsenceTest mencakup dua penyebab umum "resource saya hilang" yang merupakan kesalahan konfigurasi, bukan keputusan bisnis: tidak ada policy sama sekali dan cached manifest yang dibuat sebelum resource tersebut tersedia.
Menulis negative test untuk resource sendiri
Checklist yang layak dimiliki resource baru, mengikuti urutan kegagalan yang biasanya terjadi:
- Akses tanpa otorisasi menghasilkan 403 pada setiap route, termasuk write verb dan action endpoint.
- Search hanya mencocokkan column yang di-whitelist.
- Sort column yang tidak dikenal atau tidak sortable diabaikan, dan applied state membuktikan hal tersebut.
perPagedibatasi; nilai filter invalid ditolak.- Record di-resolve melalui
Resource::query(), sehingga key di luar scope menghasilkan 404 pada view page, edit page, edit submission, record action, bulk action, cell endpoint, dan global search. - Create hanya memvalidasi serta menyimpan field yang dideklarasikan.
- Update membiarkan password yang tidak disentuh tetap seperti semula.
- Delete mengotorisasi per record; bulk selection yang mengandung satu record terlarang tidak mengubah apa pun.
- Tabel dan form yang diserialisasi tidak membawa closure maupun nama class internal.
- List route tidak membuat query per row.
Gunakan dua aturan saat menulis test tersebut. Assert perilaku, bukan sekadar status code — assertOk() sendirian hanya membuktikan sedikit hal, sementara 200 pada list page justru adalah bentuk sempurna dari scope yang bocor tetapi terlihat normal. Lalu buktikan guard benar-benar load-bearing: hapus guard, jalankan test, dan pastikan test gagal. Negative test yang tetap lulus tanpa guard sebenarnya sedang menguji hal lain.
Hal yang perlu diperhatikan
- Tanam decoy file. Traversal test terhadap directory yang tidak ada akan lulus karena alasan yang salah.
- Setiap file perlu control test. Misalnya "…agar penolakan di atas terbukti berasal dari scope, bukan karena route rusak".
- Percent-encode input yang memang akan ditolak HTTP layer dalam bentuk mentah. Raw backslash dapat ditolak sebelum aplikasi melihatnya, yang berarti guard berbeda sedang diuji.
UploadedFile::fake()tidak dapat dipercaya untuk test MIME berdasarkan content. Fake menentukan MIME berdasarkan nama file. Test tentang content harus menggunakanUploadedFilenyata.- Loop harus memiliki pesan.
expect($status)->toBeIn(answeredStatuses(), "payload #{$index} produced {$status}")membuat kegagalan langsung menunjukkan iterasi mana yang bermasalah. - 404 versus 403 adalah keputusan keamanan. Record di luar scope adalah 404 karena bagi resource record tersebut tidak ada; record yang ada tetapi ditolak policy adalah 403. Menukar keduanya dapat membocorkan keberadaan data atau menyembunyikan bug.
Lihat juga
- Pengujian otorisasi dan tenancy
- Pengujian tabel — sisi positif dari surface yang sama
- Keamanan search
- Checklist keamanan tenancy
- File upload
- Otorisasi channel