Ctrl K
ComunidadGuías…

tests/Feature/ComposeContentVolumeConfinementTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Livewire\Project\Application\General;
use App\Livewire\Project\Service\StackForm;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Livewire\Livewire;

/**
 * Only administrators can edit a Compose file, and they can mount any host path. Coolify writes the
 * `content:` of a Compose bind volume to the source path, like Coolify v4.3.23 did, also outside
 * the resource directory. Every path in a remote command is shell-escaped, and the source must not
 * contain shell metacharacters.
 */
uses(RefreshDatabase::class);

const CONTENT_HOST_PATH_SAFE_COMPOSE = "services:\n  app:\n    image: nginx:alpine\n";

const CONTENT_HOST_PATH_COMPOSE = <<<'YAML'
services:
  app:
    image: nginx:alpine
    volumes:
      - type: bind
        source: /etc/myapp/app.conf
        target: /etc/a.conf
        content: a
      - type: bind
        source: ~/x/app.conf
        target: /etc/b.conf
        content: b
      - type: bind
        source: app.conf
        target: /etc/c.conf
        content: c
      - type: bind
        source: ./../shared/app.conf
        target: /etc/d.conf
        content: d
YAML;

const CONTENT_HOST_PATH_INJECTION_COMPOSE = <<<'YAML'
services:
  app:
    image: nginx:alpine
    volumes:
      - type: bind
        source: /etc/$(id)/app.conf
        target: /etc/app.conf
        content: x
YAML;

beforeEach(function () {
    Server::flushIdentityMap();
    Bus::fake();
    InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
    config([
        'app.maintenance.store' => 'array',
        'constants.ssh.mux_enabled' => false,
    ]);

    $this->team = Team::factory()->create();
    $this->user = User::factory()->create();
    $this->team->members()->attach($this->user->id, ['role' => 'owner']);
    session(['currentTeam' => $this->team]);

    $this->server = Server::factory()->create([
        'team_id' => $this->team->id,
        'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
    ]);
    $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $this->environment = Environment::factory()->create(['project_id' => $project->id]);
});

afterEach(function () {
    Server::flushIdentityMap();
});

function contentHostPathService(string $compose): Service
{
    return Service::factory()->create([
        'name' => 'content-host-path',
        'environment_id' => test()->environment->id,
        'server_id' => test()->server->id,
        'destination_id' => test()->destination->id,
        'destination_type' => test()->destination->getMorphClass(),
        'docker_compose_raw' => $compose,
        'connect_to_docker_network' => false,
    ]);
}

function contentHostPathApplication(string $compose): Application
{
    return Application::factory()->create([
        'environment_id' => test()->environment->id,
        'destination_id' => test()->destination->id,
        'destination_type' => test()->destination->getMorphClass(),
        'build_pack' => 'dockercompose',
        'git_repository' => 'https://github.com/coollabsio/compose-app',
        'git_branch' => 'main',
        'base_directory' => '/',
        'docker_compose_location' => '/docker-compose.yml',
        'docker_compose_raw' => $compose,
    ]);
}

function contentHostPathUseServerUser(string $user): void
{
    test()->server->update(['user' => $user]);
    Server::flushIdentityMap();
}

/**
 * Fakes the server: every checked path is missing, the symlink check prints OK.
 */
function contentHostPathFakeServer(): void
{
    Process::fake(fn ($process) => Process::result(output: match (true) {
        str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
        str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
        str_contains(remoteProcessScript($process), 'test -') => 'NOK',
        default => '',
    }));
}

/**
 * The stored fs_path and the host path that Coolify writes, for a source in CONTENT_HOST_PATH_COMPOSE.
 * `~` and `./` are relative to the resource directory, as in v4.3.23. A bare relative path is
 * stored as written and written inside the resource directory (never relative to the SSH user's
 * working directory).
 *
 * @return array{0: string, 1: string}
 */
function contentHostPathExpected(string $target, string $workdir): array
{
    $parent = dirname($workdir);

    return match ($target) {
        '/etc/a.conf' => ['/etc/myapp/app.conf', '/etc/myapp/app.conf'],
        '/etc/b.conf' => [$workdir.'/x/app.conf', $workdir.'/x/app.conf'],
        '/etc/c.conf' => ['app.conf', $workdir.'/app.conf'],
        '/etc/d.conf' => [$parent.'/shared/app.conf', $parent.'/shared/app.conf'],
    };
}

function contentHostPathAssertWritten(string $path, string $content, string $user): void
{
    $sudo = $user === 'root' ? '' : 'sudo ';
    $escapedPath = escapeshellarg($path);
    $escapedParent = escapeshellarg(dirname($path));
    $base64 = base64_encode($content);

    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p {$escapedParent}"));
    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee {$escapedPath} > /dev/null"));
}

/**
 * @return array<string, array{string, string}>
 */
function contentHostPathTargets(): array
{
    return [
        'absolute host file' => ['/etc/a.conf', 'a'],
        'home directory' => ['/etc/b.conf', 'b'],
        'bare relative path' => ['/etc/c.conf', 'c'],
        'parent directory' => ['/etc/d.conf', 'd'],
    ];
}

test('the service parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) {
    contentHostPathUseServerUser($user);
    $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
    serviceParser($service);

    [$fsPath, $writePath] = contentHostPathExpected($target, $service->workdir());
    $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
    expect($fileVolume->fs_path)->toBe($fsPath)
        ->and($fileVolume->content)->toBe($content)
        ->and($fileVolume->is_directory)->toBeFalse()
        ->and($fileVolume->contentPathOnServer())->toBe($writePath);

    contentHostPathFakeServer();
    $fileVolume->saveStorageOnServer();

    contentHostPathAssertWritten($writePath, $content, $user);
})->with(contentHostPathTargets())->with(['root', 'coolify']);

test('the application parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) {
    contentHostPathUseServerUser($user);
    $application = contentHostPathApplication(CONTENT_HOST_PATH_COMPOSE);
    applicationParser($application);

    [$fsPath, $writePath] = contentHostPathExpected($target, $application->workdir());
    $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
    expect($fileVolume->fs_path)->toBe($fsPath)
        ->and($fileVolume->content)->toBe($content)
        ->and($fileVolume->contentPathOnServer())->toBe($writePath);

    contentHostPathFakeServer();
    $fileVolume->saveStorageOnServer();

    contentHostPathAssertWritten($writePath, $content, $user);
})->with(contentHostPathTargets())->with(['root', 'coolify']);

test('loading files for a service writes content inside the resource directory', function (string $target, string $content, string $user) {
    contentHostPathUseServerUser($user);
    $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
    serviceParser($service);
    $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
    [, $writePath] = contentHostPathExpected($target, $service->workdir());

    contentHostPathFakeServer();
    getFilesystemVolumesFromServer($fileVolume->resource, true);

    $sudo = $user === 'root' ? '' : 'sudo ';
    $escapedPath = escapeshellarg($writePath);
    $base64 = base64_encode($content);
    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\""));
    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}"));
})->with([
    'home directory' => ['/etc/b.conf', 'b'],
    'bare relative path' => ['/etc/c.conf', 'c'],
])->with(['root', 'coolify']);

// A token with only the write permission can save a Compose file. Content outside the resource
// directory is written when the resource is deployed or started, not when its files are loaded.
test('loading files for a service does not write content outside the resource directory', function (string $target, string $content) {
    $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
    serviceParser($service);
    $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();

    contentHostPathFakeServer();
    getFilesystemVolumesFromServer($fileVolume->resource, true);

    Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode($content)));
})->with([
    'absolute host file' => ['/etc/a.conf', 'a'],
    'parent directory' => ['/etc/d.conf', 'd'],
]);

test('a quote in a content path is shell-escaped in every write command', function (string $user) {
    contentHostPathUseServerUser($user);
    $path = "/etc/my'app/app.conf";
    $compose = "services:\n  app:\n    image: nginx:alpine\n    volumes:\n      - type: bind\n        source: \"{$path}\"\n        target: /etc/app.conf\n        content: x\n";
    $service = contentHostPathService($compose);
    serviceParser($service);
    $fileVolume = LocalFileVolume::query()->sole();
    expect($fileVolume->fs_path)->toBe($path);

    contentHostPathFakeServer();
    $fileVolume->saveStorageOnServer();

    $sudo = $user === 'root' ? '' : 'sudo ';
    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null"));
    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p '/etc/my'\\''app'"));
})->with(['root', 'coolify']);

test('the parsers reject shell injection in a content volume source', function () {
    expect(fn () => serviceParser(contentHostPathService(CONTENT_HOST_PATH_INJECTION_COMPOSE)))
        ->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)')
        ->and(fn () => applicationParser(contentHostPathApplication(CONTENT_HOST_PATH_INJECTION_COMPOSE)))
        ->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)');

    expect(LocalFileVolume::query()->count())->toBe(0);
});

test('a bind mount without content keeps its administrator-selected host path', function () {
    $compose = "services:\n  app:\n    image: nginx:alpine\n    volumes:\n      - /srv/shared:/keys\n";
    $service = contentHostPathService($compose);
    $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
    $fileVolume = LocalFileVolume::create([
        'fs_path' => '/srv/shared',
        'mount_path' => '/keys',
        'is_directory' => true,
        'resource_id' => $serviceApplication->id,
        'resource_type' => $serviceApplication->getMorphClass(),
    ])->fresh();

    contentHostPathFakeServer();
    $fileVolume->saveStorageOnServer();

    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/srv/shared'"));
    Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'readlink -f'));
});

test('the service stack form saves content volumes with host paths', function () {
    $this->actingAs($this->user);
    $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
    contentHostPathFakeServer();

    Livewire::test(StackForm::class, ['service' => $service])
        ->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE)
        ->call('submit')
        ->assertNotDispatched('error');

    expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
        ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});

test('the service stack form rejects shell injection in a content volume source', function () {
    $this->actingAs($this->user);
    $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);

    Livewire::test(StackForm::class, ['service' => $service])
        ->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE)
        ->call('submit')
        ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition'));

    expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
        ->and(LocalFileVolume::query()->count())->toBe(0);
});

test('the application General form saves content volumes with host paths', function () {
    $this->actingAs($this->user);
    $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
    contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE);

    Livewire::test(General::class, ['application' => $application->fresh()])
        ->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE)
        ->call('submit')
        ->assertNotDispatched('error');

    expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
        ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});

test('the application General form rejects shell injection in a content volume source', function () {
    $this->actingAs($this->user);
    $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
    Process::fake();

    Livewire::test(General::class, ['application' => $application->fresh()])
        ->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE)
        ->call('submit')
        ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition'))
        ->assertNotDispatched('success');

    expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
        ->and(LocalFileVolume::query()->count())->toBe(0);
});

function contentHostPathFakeGitCompose(string $compose): void
{
    Process::fake(function ($process) use ($compose) {
        $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process);

        return Process::result(output: match (true) {
            str_contains($command, 'git cat-file blob') => $compose,
            default => '',
        });
    });
}

test('loading a Git Compose file accepts content volumes with host paths', function () {
    $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
    contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE);

    $application->loadComposeFile();

    expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
        ->and(LocalFileVolume::query()->orderBy('mount_path')->pluck('fs_path')->all())->toBe([
            '/etc/myapp/app.conf',
            $application->workdir().'/x/app.conf',
            'app.conf',
            dirname($application->workdir()).'/shared/app.conf',
        ]);
});

test('loading a Git Compose file rejects shell injection in a content volume source', function () {
    $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
    contentHostPathFakeGitCompose(CONTENT_HOST_PATH_INJECTION_COMPOSE);

    expect(fn () => $application->loadComposeFile())
        ->toThrow(Exception::class, 'Invalid Docker volume definition');

    expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
        ->and(LocalFileVolume::query()->count())->toBe(0);
});

function contentHostPathPatchService(Service $service, string $compose): TestResponse
{
    $plainTextToken = Str::random(40);
    $token = test()->user->tokens()->create([
        'name' => 'content-host-path',
        'token' => hash('sha256', $plainTextToken),
        'abilities' => ['*'],
        'team_id' => test()->team->id,
    ]);

    return test()->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken])
        ->patchJson("/api/v1/services/{$service->uuid}", ['docker_compose_raw' => base64_encode($compose)]);
}

test('the service API saves content volumes with host paths', function () {
    $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
    contentHostPathFakeServer();

    contentHostPathPatchService($service, CONTENT_HOST_PATH_COMPOSE)->assertSuccessful();

    expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
        ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});

test('the service API rejects shell injection in a content volume source', function () {
    $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);

    contentHostPathPatchService($service, CONTENT_HOST_PATH_INJECTION_COMPOSE)
        ->assertStatus(422)
        ->assertJsonPath('errors.docker_compose_raw', fn (string $error): bool => str_contains($error, 'Invalid Docker volume definition'));

    expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
        ->and(LocalFileVolume::query()->count())->toBe(0);
});

test('loading files for a service does not create directories outside the resource directory', function () {
    $compose = "services:\n  app:\n    image: nginx:alpine\n    volumes:\n      - type: bind\n        source: /etc/cron.d/coolify-test\n        target: /data\n";
    $service = contentHostPathService($compose);
    serviceParser($service);
    $fileVolume = LocalFileVolume::query()->sole();

    contentHostPathFakeServer();
    getFilesystemVolumesFromServer($fileVolume->resource, true);

    Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), '/etc/cron.d/coolify-test') && str_contains(remoteProcessScript($process), 'mkdir'));
});