chore: prepare for PR
This commit is contained in:
parent
cb759b2846
commit
30c0b37689
6 changed files with 259 additions and 136 deletions
|
|
@ -2756,28 +2756,46 @@ private function generate_local_persistent_volumes_only_volume_names()
|
|||
private function generate_healthcheck_commands()
|
||||
{
|
||||
if (! $this->application->health_check_port) {
|
||||
$health_check_port = $this->application->ports_exposes_array[0];
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
} else {
|
||||
$health_check_port = $this->application->health_check_port;
|
||||
$health_check_port = (int) $this->application->health_check_port;
|
||||
}
|
||||
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
|
||||
$health_check_port = 80;
|
||||
}
|
||||
if ($this->application->health_check_path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
||||
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
||||
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
|
||||
$path = $this->application->health_check_path
|
||||
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
|
||||
: null;
|
||||
|
||||
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
||||
$method = escapeshellarg($method);
|
||||
|
||||
if ($path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
||||
} else {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
|
||||
];
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
|
||||
}
|
||||
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
return implode(' ', $generated_healthchecks_commands);
|
||||
}
|
||||
|
||||
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
|
||||
{
|
||||
if (preg_match($pattern, $value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function pull_latest_image($image)
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
|
||||
|
|
|
|||
|
|
@ -16,19 +16,19 @@ class HealthChecks extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $healthCheckEnabled = false;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
||||
public string $healthCheckMethod;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'in:http,https'])]
|
||||
public string $healthCheckScheme;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
|
||||
public string $healthCheckHost;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
|
||||
public ?string $healthCheckPort = null;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
|
||||
public string $healthCheckPath;
|
||||
|
||||
#[Validate(['integer'])]
|
||||
|
|
@ -54,12 +54,12 @@ class HealthChecks extends Component
|
|||
|
||||
protected $rules = [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckPath' => 'string',
|
||||
'healthCheckPort' => 'nullable|string',
|
||||
'healthCheckHost' => 'string',
|
||||
'healthCheckMethod' => 'string',
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
'healthCheckReturnCode' => 'integer',
|
||||
'healthCheckScheme' => 'string',
|
||||
'healthCheckScheme' => 'required|string|in:http,https',
|
||||
'healthCheckResponseText' => 'nullable|string',
|
||||
'healthCheckInterval' => 'integer|min:1',
|
||||
'healthCheckTimeout' => 'integer|min:1',
|
||||
|
|
|
|||
|
|
@ -104,12 +104,12 @@ function sharedDataApplications()
|
|||
'base_directory' => 'string|nullable',
|
||||
'publish_directory' => 'string|nullable',
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_path' => 'string',
|
||||
'health_check_port' => 'string|nullable',
|
||||
'health_check_host' => 'string',
|
||||
'health_check_method' => 'string',
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
||||
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
|
||||
'health_check_return_code' => 'numeric',
|
||||
'health_check_scheme' => 'string',
|
||||
'health_check_scheme' => 'string|in:http,https',
|
||||
'health_check_response_text' => 'string|nullable',
|
||||
'health_check_interval' => 'numeric',
|
||||
'health_check_timeout' => 'numeric',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
211
tests/Unit/HealthCheckCommandInjectionTest.php
Normal file
211
tests/Unit/HealthCheckCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('sanitizes health_check_host to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'localhost; id > /tmp/pwned #',
|
||||
]);
|
||||
|
||||
// Should fall back to 'localhost' because input contains shell metacharacters
|
||||
expect($result)->not->toContain('; id')
|
||||
->and($result)->not->toContain('/tmp/pwned')
|
||||
->and($result)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_method to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET; curl http://evil.com #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('evil.com')
|
||||
->and($result)->not->toContain('; curl');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_path to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_path' => '/health; rm -rf / #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('rm -rf')
|
||||
->and($result)->not->toContain('; rm');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_scheme to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_scheme' => 'http; cat /etc/passwd #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('/etc/passwd')
|
||||
->and($result)->not->toContain('; cat');
|
||||
});
|
||||
|
||||
it('casts health_check_port to integer to prevent injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_port' => '8080; whoami',
|
||||
]);
|
||||
|
||||
// (int) cast on non-numeric after digits yields 8080
|
||||
expect($result)->not->toContain('whoami')
|
||||
->and($result)->toContain('8080');
|
||||
});
|
||||
|
||||
it('generates valid healthcheck command with safe inputs', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => '8080',
|
||||
'health_check_path' => '/health',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->toContain('http://localhost:8080/health')
|
||||
->and($result)->toContain('wget -q -O-');
|
||||
});
|
||||
|
||||
it('uses escapeshellarg on the constructed URL', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'my-app.local',
|
||||
'health_check_path' => '/api/health',
|
||||
]);
|
||||
|
||||
// escapeshellarg wraps in single quotes
|
||||
expect($result)->toContain("'http://my-app.local:80/api/health'");
|
||||
});
|
||||
|
||||
it('validates health_check_host rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_host' => 'localhost; id #'],
|
||||
['health_check_host' => $rules['health_check_host']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_method rejects invalid methods via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_method' => 'GET; curl evil.com'],
|
||||
['health_check_method' => $rules['health_check_method']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_scheme' => 'http; whoami'],
|
||||
['health_check_scheme' => $rules['health_check_scheme']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_path rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_path' => '/health; rm -rf /'],
|
||||
['health_check_path' => $rules['health_check_path']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_port rejects non-numeric values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_port' => '8080; whoami'],
|
||||
['health_check_port' => $rules['health_check_port']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows valid health check values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
[
|
||||
'health_check_host' => 'my-app.localhost',
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'https',
|
||||
'health_check_path' => '/api/v1/health',
|
||||
'health_check_port' => 8080,
|
||||
],
|
||||
[
|
||||
'health_check_host' => $rules['health_check_host'],
|
||||
'health_check_method' => $rules['health_check_method'],
|
||||
'health_check_scheme' => $rules['health_check_scheme'],
|
||||
'health_check_path' => $rules['health_check_path'],
|
||||
'health_check_port' => $rules['health_check_port'],
|
||||
]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
||||
*/
|
||||
function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||
{
|
||||
$defaults = [
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => null,
|
||||
'health_check_path' => '/',
|
||||
'ports_exposes' => '80',
|
||||
];
|
||||
|
||||
$values = array_merge($defaults, $overrides);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$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_host')->andReturn($values['health_check_host']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
|
||||
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
|
||||
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$appProp = $reflection->getProperty('application');
|
||||
$appProp->setAccessible(true);
|
||||
$appProp->setValue($job, $application);
|
||||
|
||||
$method = $reflection->getMethod('generate_healthcheck_commands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($job);
|
||||
}
|
||||
Loading…
Reference in a new issue