banip ` 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 } }