diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index b96dabadb9..8c19533688 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', @@ -161,21 +162,6 @@ class ApplicationsController extends Controller return $payload; } - /** - * Setting or changing a manual webhook secret needs the deploy ability. Sending the stored value - * again or clearing it does not. - */ - private function manualWebhookSecretForbiddenResponse(Request $request, ?Application $application = null): ?JsonResponse - { - foreach (['manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea'] as $field) { - if ($request->filled($field) && $request->input($field) !== $application?->{$field}) { - return $this->deployAbilityForbiddenResponse($request, 'A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.'); - } - } - - return null; - } - private function applicationSettingsFromRequest(Request $request): array { $settings = []; @@ -1545,9 +1531,6 @@ class ApplicationsController extends Controller if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) { return $internalNameValidation; } - if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request)) { - return $forbidden; - } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], @@ -3526,10 +3509,6 @@ class ApplicationsController extends Controller 'errors' => $errors, ], 422); } - if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request, $application)) { - return $forbidden; - } - $githubApp = null; if ($request->has('github_app_uuid')) { if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') { diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 39ef203226..a397d81d85 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -251,6 +251,7 @@ class DatabasesController extends Controller 'mysql_root_password', 'mariadb_password', 'mariadb_root_password', + ...$database::CONFIGURATION_ATTRIBUTES, ]); $this->exposeNestedServerSecrets($database); } else { 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/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index c8badb3cf7..b96ef411a0 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -137,17 +137,17 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; $this->clickhouseAdminUser = $this->database->clickhouse_admin_user; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->clickhouseAdminPassword = $canSeeCredentials ? $this->database->clickhouse_admin_password : ''; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; } } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 0e35530841..f2821121bd 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -124,16 +124,16 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->dragonflyPassword = $canSeeCredentials ? $this->database->dragonfly_password : ''; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; } } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index abd9e5d025..8ab367e054 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -128,9 +128,9 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->keydbConf = $canSeeCredentials ? $this->database->keydb_conf : null; $this->keydbPassword = $canSeeCredentials ? $this->database->keydb_password : ''; $this->image = $this->database->image; @@ -138,7 +138,7 @@ class General extends Component $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; } } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 9e951db774..bed0d6beea 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -153,9 +153,9 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->mariadbRootPassword = $canSeeCredentials ? $this->database->mariadb_root_password : ''; $this->mariadbUser = $this->database->mariadb_user; $this->mariadbPassword = $canSeeCredentials ? $this->database->mariadb_password : ''; @@ -167,7 +167,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; } } diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 3943512636..545e8463f6 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -145,9 +145,9 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->mongoConf = $canSeeCredentials ? $this->database->mongo_conf : null; $this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username; $this->mongoInitdbRootPassword = $canSeeCredentials ? $this->database->mongo_initdb_root_password : ''; @@ -158,7 +158,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; } } diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index f05f9b8410..efda816200 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -153,9 +153,9 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->mysqlRootPassword = $canSeeCredentials ? $this->database->mysql_root_password : ''; $this->mysqlUser = $this->database->mysql_user; $this->mysqlPassword = $canSeeCredentials ? $this->database->mysql_password : ''; @@ -167,7 +167,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; } } diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index f5844d93d1..ee546680f0 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -169,10 +169,10 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; $this->postgresUser = $this->database->postgres_user; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->postgresPassword = $canSeeCredentials ? $this->database->postgres_password : ''; $this->postgresDb = $this->database->postgres_db; $this->postgresInitdbArgs = $this->database->postgres_initdb_args; @@ -185,7 +185,7 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; } } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index f744f8eed6..9ab4159385 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -146,6 +146,7 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; $this->image = $this->database->image; @@ -154,10 +155,9 @@ class General extends Component $this->publicPort = $this->database->public_port; $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; - $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->redisConf = $canSeeCredentials ? $this->database->redis_conf : null; $this->isPasswordLocked = $this->database->isCredentialLocked(); $this->redisPassword = $canSeeCredentials && ! $this->isPasswordLocked ? $this->database->redis_password : null; diff --git a/app/Livewire/Project/Database/Sqlite/General.php b/app/Livewire/Project/Database/Sqlite/General.php index cd1d578554..9c79e55a40 100644 --- a/app/Livewire/Project/Database/Sqlite/General.php +++ b/app/Livewire/Project/Database/Sqlite/General.php @@ -29,6 +29,8 @@ class General extends Component public bool $isLogDrainEnabled = false; + public bool $isPasswordHiddenForMember = false; + public function mount() { try { @@ -43,6 +45,8 @@ class General extends Component } catch (\Throwable $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false); } protected function rules(): array @@ -83,11 +87,12 @@ class General extends Component $this->database->save(); $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { + $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; $this->name = $this->database->name; $this->description = $this->database->description; $this->sqliteDatabases = $this->database->sqlite_databases; $this->image = $this->database->image; - $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; } } 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 6e4108f9af..2a32c515e0 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -258,6 +258,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 @@ -265,6 +283,7 @@ class Application extends BaseModel * job, compose generation) must makeVisible explicitly before toArray(). */ protected $hidden = [ + ...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'http_basic_auth_password', 'git_https_password', 'manual_webhook_secret_github', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 50a4bd56ad..63a26b16e0 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -63,6 +63,14 @@ class StandaloneClickhouse extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -70,6 +78,7 @@ class StandaloneClickhouse extends BaseModel */ protected $hidden = [ 'clickhouse_admin_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', ]; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index ecb844beb8..1386309d47 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -57,6 +57,14 @@ class StandaloneDragonfly extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -64,6 +72,7 @@ class StandaloneDragonfly extends BaseModel */ protected $hidden = [ 'dragonfly_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', // Internal start-command state, not part of the API. diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 0c8353e765..b54c93f637 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -58,6 +58,15 @@ class StandaloneKeydb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'keydb_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -65,6 +74,7 @@ class StandaloneKeydb extends BaseModel */ protected $hidden = [ 'keydb_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', // Internal start-command state, not part of the API. diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 6457f00fe8..7fd842d001 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -61,6 +61,15 @@ class StandaloneMariadb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'mariadb_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -69,6 +78,7 @@ class StandaloneMariadb extends BaseModel protected $hidden = [ 'mariadb_password', 'mariadb_root_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', ]; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 3828977ff0..189cd11de3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -61,6 +61,15 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'mongo_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -68,6 +77,7 @@ class StandaloneMongodb extends BaseModel */ protected $hidden = [ 'mongo_initdb_root_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', ]; diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 29c21cd710..17ff604864 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -62,6 +62,15 @@ class StandaloneMysql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'mysql_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -70,6 +79,7 @@ class StandaloneMysql extends BaseModel protected $hidden = [ 'mysql_password', 'mysql_root_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', ]; diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 3d4c9ef035..c266ae8583 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -64,6 +64,15 @@ class StandalonePostgresql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'postgres_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -72,6 +81,7 @@ class StandalonePostgresql extends BaseModel protected $hidden = [ 'postgres_password', 'init_scripts', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', ]; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index c68632132a..96f1c30d39 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -59,6 +59,15 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'redis_conf', + 'custom_docker_run_options', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -66,6 +75,7 @@ class StandaloneRedis extends BaseModel */ protected $hidden = [ 'redis_password', + ...self::CONFIGURATION_ATTRIBUTES, 'internal_db_url', 'external_db_url', // Internal start-command state, not part of the API. diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 5162a72fc0..5375720604 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -58,6 +58,23 @@ class StandaloneSqlite extends BaseModel 'health_check_start_period', ]; + /** + * Free-form configuration that can embed credentials. Only users who can + * update the database and `read:sensitive` tokens may read it. + */ + public const CONFIGURATION_ATTRIBUTES = [ + 'custom_docker_run_options', + ]; + + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + ...self::CONFIGURATION_ATTRIBUTES, + ]; + protected $appends = ['database_type', 'server_status']; protected $casts = [ diff --git a/app/Notifications/Dto/PushoverMessage.php b/app/Notifications/Dto/PushoverMessage.php index abf6f1b7ac..42f574029d 100644 --- a/app/Notifications/Dto/PushoverMessage.php +++ b/app/Notifications/Dto/PushoverMessage.php @@ -2,8 +2,6 @@ namespace App\Notifications\Dto; -use Illuminate\Support\Facades\Log; - class PushoverMessage { public function __construct( @@ -43,8 +41,6 @@ class PushoverMessage $payload['message'] .= " ".$text.''; } - Log::info('Pushover message', $payload); - return $payload; } } diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index 851c47fa37..36d3a4b544 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -54,6 +54,17 @@ tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(databa !skip { print } AWK; + /** + * mysql and mariadb client commands that run a shell command, write or read a file: + * system, tee, pager, edit and source, as a long command at the start of a line or as + * a short command (\!, \T, \P, \e, \.) anywhere. An odd backslash run starts a short + * command; mysqldump writes a backslash in data as "\\" and escapes only 0, n, r, Z and + * quotes, so its rows do not match. + */ + private const MYSQL_CLIENT_COMMAND_PATTERN = '(^|[^\\\\])(\\\\\\\\)*\\\\[!TPe.]'; + + private const MYSQL_NAMED_CLIENT_COMMAND_PATTERN = '^[[:space:]]*(system|tee|pager|edit|source)([[:space:]]|;|$)'; + /** * Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks. */ @@ -317,24 +328,42 @@ SH; * MySQL and MariaDB restore SQL dumps. A tar backup must wrap exactly one dump. * The all-databases mode checks the backup before it drops any database and skips * the system databases unless their users and privileges are restored as well. + * The client runs the dump with its client commands disabled: --sandbox on MariaDB + * (10.5.25, 10.6.18, 10.11.8, 11.4.2 and later), --commands=FALSE on MySQL (8.0.43, 8.4.6, + * 9.4.0 and later). Older clients do not know these options, so a dump with a client + * command that runs a shell command or uses a file is refused on every client. */ private function mysql(string $binary, string $prefix, bool $dumpAll, bool $restoreMysqlUsers = false): string { - $preflight = <<<'SH' + $commandPattern = escapeshellarg(self::MYSQL_CLIENT_COMMAND_PATTERN); + $namedCommandPattern = escapeshellarg(self::MYSQL_NAMED_CLIENT_COMMAND_PATTERN); + $preflight = <</dev/null | grep -q -e '--sandbox'; then + client_safety=--sandbox +elif {$binary} --help 2>/dev/null | grep -q -e '--commands'; then + client_safety=--commands=FALSE +fi SH; if (! $dumpAll) { - return $preflight.<</dev/null || true; done {$root} -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | {$root} || exit 1 {$root} -e "CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;" || exit 1 -{$source} | {$root} {$database} +{$source} | {$binary} \$client_safety -u root -p\${{$prefix}_ROOT_PASSWORD} {$database} SH; } diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index bc9cfce208..d38027dfd0 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -104,11 +104,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index ada8007f0e..745059ed4b 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -88,11 +88,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index dacb2175e3..cd757fa14d 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -85,11 +85,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index 8d7b3ce8fd..2fbcb6d473 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -88,11 +88,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index f2466fc1e0..342e79470d 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -90,11 +90,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index c199e525ad..2517ba86ee 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -90,11 +90,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif @if ($isPasswordHiddenForMember) diff --git a/resources/views/livewire/project/database/sqlite/general.blade.php b/resources/views/livewire/project/database/sqlite/general.blade.php index ebe9fa1ca5..cc3a45c7bf 100644 --- a/resources/views/livewire/project/database/sqlite/general.blade.php +++ b/resources/views/livewire/project/database/sqlite/general.blade.php @@ -31,11 +31,15 @@ - + @if ($isPasswordHiddenForMember) + + @else + + @endif application->uuid}", [$field => 'attacker-secret-0123456789']) - ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy. A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.'); + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'new-secret-0123456789']) + ->assertOk(); - $application = $this->application->fresh(); - expect($application->manual_webhook_secret_github)->toBe('existing-secret-0123456789'); - expect($application->{$field})->not->toBe('attacker-secret-0123456789'); + expect($this->application->fresh()->{$field})->toBe('new-secret-0123456789'); })->with([ 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', ]); - - test('write-only token can update other fields and send the stored secret back', function () { - $headers = manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']); - - $this->withHeaders($headers) - ->patchJson("/api/v1/applications/{$this->application->uuid}", ['description' => 'changed']) - ->assertOk(); - $this->withHeaders($headers) - ->patchJson("/api/v1/applications/{$this->application->uuid}", [ - 'description' => 'changed again', - 'manual_webhook_secret_github' => 'existing-secret-0123456789', - ]) - ->assertOk(); - - expect($this->application->fresh()) - ->description->toBe('changed again') - ->manual_webhook_secret_github->toBe('existing-secret-0123456789'); - }); - - test('write and deploy token can set a new manual webhook secret', function () { - $this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) - ->patchJson("/api/v1/applications/{$this->application->uuid}", ['manual_webhook_secret_gitlab' => 'new-gitlab-secret-0123456789']) - ->assertOk(); - - expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('new-gitlab-secret-0123456789'); - }); }); describe('create', function () { - test('write-only token cannot create an application with a manual webhook secret', function () { + test('write-only token can create an application with a manual webhook secret', function () { $this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write'])) ->postJson('/api/v1/applications/dockerimage', [ 'project_uuid' => $this->project->uuid, @@ -102,11 +73,10 @@ describe('create', function () { 'server_uuid' => $this->server->uuid, 'docker_registry_image_name' => 'nginx', 'ports_exposes' => '80', - 'manual_webhook_secret_github' => 'attacker-secret-0123456789', + 'manual_webhook_secret_github' => 'new-secret-0123456789', ]) - ->assertForbidden() - ->assertJsonPath('message', fn (string $message) => str_contains($message, 'manual webhook secret')); + ->assertCreated(); - expect(Application::count())->toBe(1); + expect(Application::latest('id')->first()->manual_webhook_secret_github)->toBe('new-secret-0123456789'); }); }); 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-'); +}); diff --git a/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php index 23ef9f4257..f53b5d9cd4 100644 --- a/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php +++ b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php @@ -1,5 +1,7 @@ assertOk() ->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0"); }); + +dataset('database-api-config', [ + 'postgresql' => ['create_standalone_postgresql', 'postgresqls', ['postgres_conf' => "primary_conninfo = 'password=secret-config-value'"]], + 'mysql' => ['create_standalone_mysql', 'mysqls', ['mysql_conf' => 'password=secret-config-value']], + 'mariadb' => ['create_standalone_mariadb', 'mariadbs', ['mariadb_conf' => 'password=secret-config-value']], + 'mongodb' => ['create_standalone_mongodb', 'mongodbs', ['mongo_conf' => 'keyFile: secret-config-value']], + 'redis' => ['create_standalone_redis', 'redis', ['redis_conf' => 'masterauth secret-config-value']], + 'keydb' => ['create_standalone_keydb', null, ['keydb_conf' => 'masterauth secret-config-value']], + 'dragonfly' => ['create_standalone_dragonfly', null, []], + 'clickhouse' => ['create_standalone_clickhouse', null, []], +]); + +function apiTokenHeaders(User $user, Team $team, array $abilities): array +{ + $token = $user->createToken('config-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]); + + return ['Authorization' => 'Bearer '.$token->plainTextToken]; +} + +it('hides custom configs and docker run options from a token without sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) { + $database = createDatabaseWithConfig($createDatabase, [ + ...$config, + 'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value', + ]); + session(['currentTeam' => $this->team]); + $project = $this->environment->project; + + foreach ([ + [$this->member, ['read']], + [$this->member, ['read', 'read:sensitive']], + [$this->owner, ['read']], + ] as [$user, $abilities]) { + $headers = apiTokenHeaders($user, $this->team, $abilities); + + $single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk(); + expect($single->json('uuid'))->toBe($database->uuid) + ->and($single->getContent())->not->toContain('secret-config-value')->not->toContain('secret-docker-value'); + + $list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk(); + expect($list->getContent())->toContain($database->uuid) + ->not->toContain('secret-config-value')->not->toContain('secret-docker-value'); + + if ($environmentRelation !== null) { + $environment = $this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk(); + expect($environment->getContent())->toContain($database->uuid) + ->not->toContain('secret-config-value')->not->toContain('secret-docker-value'); + } + } +})->with('database-api-config'); + +it('returns custom configs and docker run options to an owner token with sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) { + $database = createDatabaseWithConfig($createDatabase, [ + ...$config, + 'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value', + ]); + session(['currentTeam' => $this->team]); + $headers = apiTokenHeaders($this->owner, $this->team, ['read', 'read:sensitive']); + + $single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk() + ->assertJsonPath('custom_docker_run_options', '--env API_TOKEN=secret-docker-value'); + foreach ($config as $key => $value) { + $single->assertJsonPath($key, $value); + } + + if ($environmentRelation !== null) { + $project = $this->environment->project; + $this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk() + ->assertJsonPath("{$environmentRelation}.0.custom_docker_run_options", '--env API_TOKEN=secret-docker-value'); + } +})->with('database-api-config'); + +dataset('database-docker-options-components', [ + 'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class], + 'mysql' => ['create_standalone_mysql', MysqlGeneral::class], + 'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class], + 'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class], + 'redis' => ['create_standalone_redis', RedisGeneral::class], + 'keydb' => ['create_standalone_keydb', KeydbGeneral::class], + 'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class], + 'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class], + 'sqlite' => ['create_standalone_sqlite', SqliteGeneral::class], +]); + +it('keeps custom docker run options out of member state and html', function (string $createDatabase, string $generalClass) { + $database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($this->member->id, ['role' => 'owner']); + + $this->actingAs($this->member); + foreach ([$this->team, $ownTeam] as $sessionTeam) { + session(['currentTeam' => $sessionTeam]); + + $general = Livewire::test($generalClass, ['database' => $database]) + ->assertSet('customDockerRunOptions', null); + expect(json_encode($general->snapshot))->not->toContain('secret-docker-value') + ->and($general->html())->not->toContain('secret-docker-value') + ->and($general->html())->toContain('Hidden (only admins can view)'); + } +})->with('database-docker-options-components'); + +it('keeps custom docker run options available to an owner', function (string $createDatabase, string $generalClass) { + $database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test($generalClass, ['database' => $database]) + ->assertSet('customDockerRunOptions', '--env API_TOKEN=secret-docker-value'); +})->with('database-docker-options-components'); + +it('does not let a member save over hidden custom configs and docker run options', function (string $createDatabase, string $generalClass) { + $database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test($generalClass, ['database' => $database]) + ->set('name', 'renamed-by-member') + ->call('submit') + ->call('instantSaveAdvanced'); + + $database = $database->fresh(); + expect($database->name)->not->toBe('renamed-by-member') + ->and($database->custom_docker_run_options)->toBe('--env API_TOKEN=secret-docker-value'); +})->with('database-docker-options-components'); diff --git a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php index c44a19ada9..2a4fac2060 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php @@ -31,13 +31,13 @@ test('builds database-specific restore commands', function (string $class, ?stri expect($command)->toContain($needle)->toContain("'/tmp/restore file'"); })->with([ 'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'], - 'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'], - 'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'], + 'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER'], + 'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER'], 'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'], 'sqlite' => [StandaloneSqlite::class, null, '.restore'], 'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'], - 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'], - 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'], + 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER'], + 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER'], 'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'], ]); @@ -51,10 +51,10 @@ test('decompresses gzip backups for single-database mysql and mariadb restores', ->toContain('is_gzip() { [ "$(head -c 2 "$backup" | od -An -tx1 | tr -d \' \n\')" = 1f8b ]; }') ->toEndWith("stream | {$client}"); })->with([ - 'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'], - 'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'], - 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'], - 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'], + 'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'], + 'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'], + 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'], + 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'], ]); test('builds dump-all commands and postgres safety scan', function () { @@ -158,7 +158,7 @@ test('skips the system databases of MySQL and MariaDB all-databases backups by d expect($command)->toContain('command -v awk') ->toMatch('/stream \| \{?\s*awk /') ->toContain('"mysql"')->toContain('"sys"')->toContain('"performance_schema"')->toContain('"information_schema"') - ->toContain("| {$binary} -u root"); + ->toContain("| {$binary} \$client_safety -u root"); })->with([ 'mysql' => [StandaloneMysql::class, 'mysql'], 'mariadb' => [StandaloneMariadb::class, 'mariadb'], @@ -168,7 +168,7 @@ test('restores the system databases of MySQL and MariaDB all-databases backups w $command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource($class), '/tmp/backup.sql', true, restoreMysqlUsers: true); expect($command)->not->toContain('awk') - ->toContain("stream | {$binary} -u root"); + ->toContain("stream | {$binary} \$client_safety -u root"); })->with([ 'mysql' => [StandaloneMysql::class, 'mysql'], 'mariadb' => [StandaloneMariadb::class, 'mariadb'], diff --git a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php index 47ebd39e69..deebb8d634 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php @@ -250,6 +250,7 @@ function restoreScriptRun(string $engine, string $contents, bool $dumpAll = fals $escapedStdin = escapeshellarg($dir.'/stdin'); file_put_contents($dir.'/bin/'.$client, << ['mysql-two-databases', false, 'This backup contains more than one database.'], ]); +test('restores MySQL and MariaDB dumps with client commands disabled when the client supports it', function (string $engine, string $help, string $flag, bool $dumpAll) { + $run = restoreScriptRun($engine, restoreScriptFixture('mysql-sql'), $dumpAll, env: ['STUB_HELP' => $help]); + + expect($run['exit'])->toBe(0, $run['stderr']) + ->and(end($run['calls'])['args'])->toStartWith($flag.' -u ') + ->and(end($run['calls'])['stdin'])->toBe(restoreScriptFixture('mysql-sql')); +})->with(['mysql', 'mariadb'])->with([ + 'MariaDB client' => [' --sandbox Disallow commands that access the file system', '--sandbox'], + 'MySQL client' => [' --commands Whether to enable or disable processing of local mysql commands.', '--commands=FALSE'], +])->with(['single database' => false, 'all databases' => true]); + +test('refuses MySQL and MariaDB dumps with client commands before calling any client', function (string $engine, string $command, bool $dumpAll) { + $dump = "-- MySQL dump 10.13\n\nCREATE TABLE `items` (`id` int);\n{$command}\nINSERT INTO `items` VALUES (1);\n"; + + foreach ([$dump, gzencode($dump)] as $contents) { + $run = restoreScriptRun($engine, $contents, $dumpAll); + + expect($run['exit'])->toBe(1) + ->and($run['stderr'])->toContain('client command')->toContain('Nothing was changed.') + ->and($run['calls'])->toBe([]); + } +})->with(['mysql', 'mariadb'])->with([ + 'system' => 'system id', + 'indented SYSTEM' => ' SYSTEM id;', + '\\!' => '\\! id', + '\\! after SQL' => 'SELECT 1; \\! id', + 'tee' => 'tee /etc/cron.d/x', + '\\T' => '\\T /etc/cron.d/x', + 'pager' => 'pager sh', + 'source' => 'source /etc/shadow', + '\\.' => '\\. /etc/shadow', +])->with(['single database' => false, 'all databases' => true]); + +test('restores MySQL and MariaDB dumps whose data only looks like client commands', function (string $engine) { + $dump = "-- MySQL dump 10.13\n/*M!999999\\- enable the sandbox mode */\nCREATE TABLE `items` (`t` text, `system` int);\nINSERT INTO `items` VALUES ('a\\\\! b\\\\\\\\T c\\'s \\\\. \\r\\n',1),('system id',2);\n"; + $run = restoreScriptRun($engine, $dump); + + expect($run['exit'])->toBe(0, $run['stderr']) + ->and(end($run['calls'])['stdin'])->toBe($dump); +})->with(['mysql', 'mariadb']); + test('restores mongodump archives', function (string $fixture, bool $replaceExisting, bool $gzip) { $run = restoreScriptRun('mongodb', restoreScriptFixture($fixture), false, $replaceExisting); diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php index 51bea457a9..98a7ccf56e 100644 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ b/tests/Unit/Livewire/Database/S3RestoreTest.php @@ -162,7 +162,7 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () { $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toStartWith("backup='/tmp/test.dump'"); - expect($result)->toContain('stream | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'); + expect($result)->toContain('stream | mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'); expect($result)->not->toContain("< '/tmp/test.dump'"); }); @@ -173,7 +173,7 @@ test('buildRestoreCommand handles MariaDB without dumpAll', function () { $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toStartWith("backup='/tmp/test.dump'"); - expect($result)->toContain('stream | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'); + expect($result)->toContain('stream | mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'); expect($result)->not->toContain("< '/tmp/test.dump'"); }); diff --git a/tests/Unit/Notifications/Dto/PushoverMessageTest.php b/tests/Unit/Notifications/Dto/PushoverMessageTest.php new file mode 100644 index 0000000000..0e7a005f8a --- /dev/null +++ b/tests/Unit/Notifications/Dto/PushoverMessageTest.php @@ -0,0 +1,27 @@ +message; + }); + + $payload = (new PushoverMessage( + title: 'Deployment failed', + message: 'Your deployment failed.', + buttons: [['text' => 'Open', 'url' => 'https://coolify.example.com/deployments/1']], + level: 'error', + ))->toPayload('app-token', 'user-key'); + + expect($payload) + ->token->toBe('app-token') + ->user->toBe('user-key') + ->and($logged)->toBeEmpty(); +});