clusev/tests/Feature/Fail2banJailValidationTest.php

100 lines
3.7 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\Fail2banService;
use App\Services\FleetService;
use App\Support\Os\OsDetector;
use App\Support\Os\OsProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
/**
* `fail2ban-client set <jail> banip <ip>` is built by interpolating the jail name. The
* base64 transport in runPrivileged() neutralises SHELL metacharacters, but a jail name
* beginning with `-` (e.g. "-h", "--help") is still parsed by fail2ban-client as an
* OPTION rather than a positional jail argument. The jail reaches this sink from a
* client-controlled source — Fail2banBan::$jail (a wire:model property) and
* Fail2banBans::unbanIp($jail) (a method argument) — so validJail() must reject a
* leading dash (and dot) while still accepting real jail names that contain an internal
* dash, like "nginx-http-auth".
*/
class Fail2banJailValidationTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(): Server
{
return Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
}
/** FleetService whose privileged exec must NEVER run (guard fires first). */
private function fleetNeverRuns(): FleetService
{
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPrivileged')->never();
return $fleet;
}
private function service(FleetService $fleet, ?OsDetector $detector = null): Fail2banService
{
return new Fail2banService($fleet, $detector ?? Mockery::mock(OsDetector::class));
}
public function test_ban_rejects_a_jail_beginning_with_a_dash(): void
{
$res = $this->service($this->fleetNeverRuns())->ban($this->server(), '-h', '1.2.3.4');
$this->assertFalse($res['ok']);
$this->assertSame(__('backend.invalid_input'), $res['output']);
}
public function test_unban_rejects_a_double_dash_option_jail(): void
{
$res = $this->service($this->fleetNeverRuns())->unban($this->server(), '--all', '1.2.3.4');
$this->assertFalse($res['ok']);
$this->assertSame(__('backend.invalid_input'), $res['output']);
}
public function test_ban_rejects_a_jail_beginning_with_a_dot(): void
{
$res = $this->service($this->fleetNeverRuns())->ban($this->server(), '.evil', '1.2.3.4');
$this->assertFalse($res['ok']);
$this->assertSame(__('backend.invalid_input'), $res['output']);
}
/**
* A real jail with an INTERNAL dash must still pass validation — proves the tightened
* regex is not over-broad. It gets PAST validJail and reaches support(), which we stub
* to report "unsupported"; the distinguishing signal is that the output is the support
* reason, NOT invalid_input (which would mean validJail wrongly rejected it).
*/
public function test_valid_jail_with_internal_dash_passes_validation(): void
{
// A real profile where fail2ban is unsupported (apk/openrc), so support() returns a reason.
$profile = new OsProfile('alpine', 'alpine', 'Alpine Linux', 'apk', 'none', 'openrc', []);
$reason = $profile->supports('fail2ban');
$this->assertNotNull($reason); // sanity: this profile really reports unsupported
$detector = Mockery::mock(OsDetector::class);
$detector->shouldReceive('detect')->andReturn($profile);
$res = $this->service($this->fleetNeverRuns(), $detector)
->unban($this->server(), 'nginx-http-auth', '1.2.3.4');
$this->assertFalse($res['ok']);
$this->assertSame($reason, $res['output']); // reached support(), not the validJail reject
}
}