diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 7b280b45fd..929f600ad4 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -126,6 +126,7 @@ class ApplicationsController extends Controller ]); if (request()->attributes->get('can_read_sensitive', false) === true) { $application->makeVisible([ + ...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'custom_labels', 'dockerfile', 'docker_compose', diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 7128f2bbc6..0069a1c21a 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -460,16 +460,17 @@ class General extends Component $this->saveApplicationSettingsWithAudit($this->application); } else { - // From model to properties + // From model to properties. Commands, configuration, and secrets can embed credentials, so only users who can update the application get them. + $canViewSecrets = auth()->user()?->can('update', $this->application) ?? false; $this->name = $this->application->name; $this->description = $this->application->description; $this->fqdn = $this->application->fqdn; $this->gitRepository = $this->application->git_repository; $this->gitBranch = $this->application->git_branch; $this->gitCommitSha = $this->application->git_commit_sha; - $this->installCommand = $this->application->install_command; - $this->buildCommand = $this->application->build_command; - $this->startCommand = $this->application->start_command; + $this->installCommand = $canViewSecrets ? $this->application->install_command : null; + $this->buildCommand = $canViewSecrets ? $this->application->build_command : null; + $this->startCommand = $canViewSecrets ? $this->application->start_command : null; $this->buildPack = $this->application->build_pack; $this->staticImage = $this->application->static_image; $this->baseDirectory = $this->application->base_directory; @@ -477,29 +478,26 @@ class General extends Component $this->portsExposes = $this->application->ports_exposes; $this->portsMappings = $this->application->ports_mappings; $this->customNetworkAliases = $this->application->custom_network_aliases; - $this->dockerfile = $this->application->dockerfile; + $this->dockerfile = $canViewSecrets ? $this->application->dockerfile : null; $this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null); $this->dockerfileTargetBuild = $this->application->dockerfile_target_build; $this->dockerRegistryImageName = $this->application->docker_registry_image_name; $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag; $this->dockerComposeLocation = $this->application->docker_compose_location; - $canViewCompose = auth()->user()?->can('update', $this->application) ?? false; - $this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null; - $this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null; - $this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command; - $this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command; - $this->customLabels = $this->application->parseContainerLabels(); - $this->customDockerRunOptions = $this->application->custom_docker_run_options; - $this->preDeploymentCommand = $this->application->pre_deployment_command; + $this->dockerCompose = $canViewSecrets ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null; + $this->dockerComposeRaw = $canViewSecrets ? $this->application->docker_compose_raw : null; + $this->dockerComposeCustomStartCommand = $canViewSecrets ? $this->application->docker_compose_custom_start_command : null; + $this->dockerComposeCustomBuildCommand = $canViewSecrets ? $this->application->docker_compose_custom_build_command : null; + $this->customLabels = $canViewSecrets ? $this->application->parseContainerLabels() : null; + $this->customDockerRunOptions = $canViewSecrets ? $this->application->custom_docker_run_options : null; + $this->preDeploymentCommand = $canViewSecrets ? $this->application->pre_deployment_command : null; $this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container; - $this->postDeploymentCommand = $this->application->post_deployment_command; + $this->postDeploymentCommand = $canViewSecrets ? $this->application->post_deployment_command : null; $this->postDeploymentCommandContainer = $this->application->post_deployment_command_container; - $this->customNginxConfiguration = $this->application->custom_nginx_configuration; + $this->customNginxConfiguration = $canViewSecrets ? $this->application->custom_nginx_configuration : null; $this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled; $this->httpBasicAuthUsername = $this->application->http_basic_auth_username; - $this->httpBasicAuthPassword = auth()->user()->can('update', $this->application) - ? $this->application->http_basic_auth_password - : null; + $this->httpBasicAuthPassword = $canViewSecrets ? $this->application->http_basic_auth_password : null; $this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value; $this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost; $this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost; diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index 1b78f65ecd..1f69370fc2 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -53,7 +53,7 @@ class ConfigurationChecker extends Component } /** - * Members must never see environment variable values or Compose content, so redact + * Members must never see environment variable values, Compose content, commands, or configuration, so redact * those changes before they are serialized to the browser. * * @param array> $changes @@ -67,8 +67,9 @@ class ConfigurationChecker extends Component return collect($changes) ->map(function (array $change): array { + $key = str((string) data_get($change, 'key'))->afterLast('.')->value(); $isHidden = data_get($change, 'section') === 'environment' - || str((string) data_get($change, 'key'))->afterLast('.')->value() === 'docker_compose_raw'; + || in_array($key, [...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'docker_compose_raw', 'dockerfile', 'custom_labels'], true); if (! $isHidden) { return $change; } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 3d9e2431ac..346f150153 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -118,17 +118,18 @@ class HealthChecks extends Component $this->resource->save(); } else { - // Sync from model + // Sync from model. The command and response text can embed credentials, so only users who can update the resource get them. + $canViewSecrets = auth()->user()?->can('update', $this->resource) ?? false; $this->healthCheckEnabled = $this->resource->health_check_enabled; $this->healthCheckType = $this->resource->health_check_type ?? 'http'; - $this->healthCheckCommand = $this->resource->health_check_command; + $this->healthCheckCommand = $canViewSecrets ? $this->resource->health_check_command : null; $this->healthCheckMethod = $this->resource->health_check_method; $this->healthCheckScheme = $this->resource->health_check_scheme; $this->healthCheckHost = $this->resource->health_check_host; $this->healthCheckPort = $this->resource->health_check_port; $this->healthCheckPath = $this->resource->health_check_path; $this->healthCheckReturnCode = $this->resource->health_check_return_code; - $this->healthCheckResponseText = $this->resource->health_check_response_text; + $this->healthCheckResponseText = $canViewSecrets ? $this->resource->health_check_response_text : null; $this->healthCheckInterval = $this->resource->health_check_interval; $this->healthCheckTimeout = $this->resource->health_check_timeout; $this->healthCheckRetries = $this->resource->health_check_retries; diff --git a/app/Models/Application.php b/app/Models/Application.php index 65f4732ca6..1fe908027b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -254,6 +254,24 @@ class Application extends BaseModel protected $appends = ['server_status']; + /** + * Free-form commands and configuration that can embed credentials. Only users who can + * update the application and `read:sensitive` tokens may read them. + */ + public const COMMAND_AND_CONFIGURATION_ATTRIBUTES = [ + 'install_command', + 'build_command', + 'start_command', + 'pre_deployment_command', + 'post_deployment_command', + 'health_check_command', + 'health_check_response_text', + 'custom_docker_run_options', + 'docker_compose_custom_start_command', + 'docker_compose_custom_build_command', + 'custom_nginx_configuration', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -261,6 +279,7 @@ class Application extends BaseModel * job, compose generation) must makeVisible explicitly before toArray(). */ protected $hidden = [ + ...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'http_basic_auth_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', diff --git a/tests/Feature/Authorization/ApplicationCommandAndConfigVisibilityTest.php b/tests/Feature/Authorization/ApplicationCommandAndConfigVisibilityTest.php new file mode 100644 index 0000000000..d85e3c5378 --- /dev/null +++ b/tests/Feature/Authorization/ApplicationCommandAndConfigVisibilityTest.php @@ -0,0 +1,215 @@ + 'installCommand', + 'build_command' => 'buildCommand', + 'start_command' => 'startCommand', + 'pre_deployment_command' => 'preDeploymentCommand', + 'post_deployment_command' => 'postDeploymentCommand', + 'custom_docker_run_options' => 'customDockerRunOptions', + 'docker_compose_custom_start_command' => 'dockerComposeCustomStartCommand', + 'docker_compose_custom_build_command' => 'dockerComposeCustomBuildCommand', + 'custom_nginx_configuration' => 'customNginxConfiguration', + 'dockerfile' => 'dockerfile', +]; + +const APP_SECRET_HEALTH_CHECK_COLUMNS = [ + 'health_check_command' => 'healthCheckCommand', + 'health_check_response_text' => 'healthCheckResponseText', +]; + +beforeEach(function () { + Queue::fake(); + $this->withoutVite(); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->team->members()->attach($this->member->id, ['role' => 'member']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->application->forceFill(applicationSecretValues())->save(); + $this->application = $this->application->fresh(); +}); + +/** + * @return array + */ +function applicationSecretValues(): array +{ + $values = []; + foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS)] as $column) { + $values[$column] = "secret-{$column}-value"; + } + $values['custom_labels'] = base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-custom_labels-value'); + + return $values; +} + +function expectApplicationSecretsAbsent(string $text): void +{ + foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS), 'custom_labels'] as $column) { + expect($text)->not->toContain("secret-{$column}-value"); + } +} + +function expectApplicationSecretsUnchanged(Application $application): void +{ + $stored = $application->fresh(); + foreach (applicationSecretValues() as $column => $value) { + expect($stored->getAttribute($column))->toBe($value); + } +} + +function applicationApiToken(User $user, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => test()->team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +it('keeps application commands, Dockerfile, and labels out of member Livewire state', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $general = Livewire::test(General::class, ['application' => $this->application]); + foreach (APP_SECRET_COLUMNS as $property) { + $general->assertSet($property, null); + } + $general->assertSet('customLabels', null); + + $healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]); + foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $property) { + $healthChecks->assertSet($property, null); + } + + expectApplicationSecretsAbsent( + json_encode($general->snapshot).$general->html().json_encode($healthChecks->snapshot).$healthChecks->html() + ); +}); + +it('keeps application commands, Dockerfile, and labels available to an owner', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $general = Livewire::test(General::class, ['application' => $this->application]); + foreach (APP_SECRET_COLUMNS as $column => $property) { + $general->assertSet($property, "secret-{$column}-value"); + } + expect($general->get('customLabels'))->toContain('secret-custom_labels-value'); + + $healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]); + foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $column => $property) { + $healthChecks->assertSet($property, "secret-{$column}-value"); + } +}); + +it('does not let a member save clear the hidden values', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + foreach (['submit', 'instantSave'] as $method) { + Livewire::test(General::class, ['application' => $this->application])->call($method); + } + foreach (['submit', 'instantSave', 'toggleHealthcheck'] as $method) { + Livewire::test(HealthChecks::class, ['resource' => $this->application])->call($method); + } + + expectApplicationSecretsUnchanged($this->application); +}); + +it('keeps an owner save from changing the values', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(HealthChecks::class, ['resource' => $this->application])->call('submit'); + + expectApplicationSecretsUnchanged($this->application); +}); + +it('hides application commands and configuration from a read token', function (string $path) { + $response = $this->withToken(applicationApiToken($this->member, ['read'])) + ->getJson(str_replace('{uuid}', $this->application->uuid, $path)) + ->assertOk(); + + expectApplicationSecretsAbsent($response->getContent()); +})->with([ + 'application' => '/api/v1/applications/{uuid}', + 'application list' => '/api/v1/applications', + 'resources' => '/api/v1/resources', +]); + +it('shows application commands and configuration to a read:sensitive token', function (string $path) { + $response = $this->withToken(applicationApiToken($this->owner, ['read', 'read:sensitive'])) + ->getJson(str_replace('{uuid}', $this->application->uuid, $path)) + ->assertOk(); + + foreach (applicationSecretValues() as $value) { + expect($response->getContent())->toContain(str_replace('/', '\/', $value)); + } +})->with([ + 'application' => '/api/v1/applications/{uuid}', + 'application list' => '/api/v1/applications', + 'resources' => '/api/v1/resources', +]); + +it('redacts command and configuration changes for members in the pending change list', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => (string) $this->application->id, + 'deployment_uuid' => (string) Str::uuid(), + 'status' => 'finished', + 'commit' => 'HEAD', + ]); + $this->application->markDeploymentConfigurationApplied($deployment); + + $changed = collect(applicationSecretValues())->map(fn (string $value, string $column) => $column === 'custom_labels' + ? base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-new-custom_labels-value') + : str_replace('secret-', 'secret-new-', $value))->all(); + $this->application->forceFill($changed)->save(); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $this->application->refresh()]); + + expect($component->get('isConfigurationChanged'))->toBeTrue(); + $text = json_encode($component->snapshot).$component->html(); + expectApplicationSecretsAbsent($text); + expect($text)->not->toContain('secret-new-'); +});