diff --git a/.ai/lessons.md b/.ai/lessons.md index 339fc7e60d..fadfc3fae0 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -17,10 +17,12 @@ - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - To assert that the unsaved-changes bar shows, check `el.classList.contains('is-dirty')`. Playwright treats the hidden bar (`opacity-0`) as visible, and its class attribute always contains the text `is-dirty` from Tailwind variants. +- In browser tests, address `x-forms.input` fields by `name` (`type('fieldName', ...)`); its HTML `id` is not always the Livewire property, so `#property` waits forever. - Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`. - In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index de01074b5a..31e9d0aba9 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -375,6 +375,8 @@ class ApplicationsController extends Controller 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host (not github.com).'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], @@ -1337,7 +1339,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1372,6 +1374,13 @@ class ApplicationsController extends Controller ], 422); } + if ($type !== 'public' && $request->hasAny(['git_https_username', 'git_https_password'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used with a public or custom Git host repository (POST /applications/public).'], + ], 422); + } + if ($request->filled('docker_compose_raw')) { try { if (! is_string($request->input('docker_compose_raw'))) { @@ -1641,6 +1650,20 @@ class ApplicationsController extends Controller } $repository_url_parsed = Url::fromString($gitRepository); $git_host = $repository_url_parsed->getHost(); + if ($request->filled('git_https_password')) { + if ($git_host === 'github.com') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials are not used for github.com. Use a GitHub App (POST /applications/private-github-app) for a private GitHub repository.'], + ], 422); + } + $application->git_https_password = $request->input('git_https_password'); + } elseif ($request->filled('git_https_username')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } if ($git_host === 'github.com') { $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; @@ -3154,6 +3177,8 @@ class ApplicationsController extends Controller 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -3360,7 +3385,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3505,6 +3530,21 @@ class ApplicationsController extends Controller } } + if ($request->hasAny(['git_https_username', 'git_https_password'])) { + if ($application->deploymentType() !== 'other') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used by applications that clone a repository without a Git source or deploy key.'], + ], 422); + } + if ($request->filled('git_https_username') && ! $request->filled('git_https_password') && blank($application->git_https_password)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } + } + $applicationSettings = $this->applicationSettingsFromRequest($request); if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; @@ -3764,6 +3804,12 @@ class ApplicationsController extends Controller $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); $application->fill($data); + if ($request->exists('git_https_password')) { + $application->git_https_password = $request->filled('git_https_password') ? $request->input('git_https_password') : null; + if (blank($application->git_https_password)) { + $application->git_https_username = null; + } + } if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) { $application->source_id = $githubApp->id; $application->source_type = $githubApp->getMorphClass(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index eefc9880bb..7b7871d763 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3103,7 +3103,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes"; } - return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + + return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function check_git_if_build_needed() diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index d2843a6780..35c71b3c5d 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -9,6 +9,7 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -40,6 +41,18 @@ class Source extends Component #[Locked] public $sources; + #[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])] + public ?string $gitHttpsUsername = null; + + /** + * Write-only: the saved password or token is never sent to the browser. + */ + #[Validate(['nullable', 'string', 'max:2048', 'regex:/^[^\r\n]*$/'])] + public ?string $gitHttpsPassword = null; + + #[Locked] + public bool $hasGitHttpsPassword = false; + public function mount() { try { @@ -70,12 +83,19 @@ class Source extends Component { if ($toModel) { $this->validate(); - $this->application->update([ + $this->application->fill([ 'git_repository' => $this->gitRepository, 'git_branch' => $this->gitBranch, 'git_commit_sha' => $this->gitCommitSha, 'private_key_id' => $this->privateKeyId, ]); + if (filled($this->gitHttpsPassword)) { + $this->application->git_https_username = $this->gitHttpsUsername; + $this->application->git_https_password = $this->gitHttpsPassword; + } elseif ($this->hasGitHttpsPassword) { + $this->application->git_https_username = $this->gitHttpsUsername; + } + $this->application->save(); // Refresh to get the trimmed values from the model $this->application->refresh(); $this->syncData(false); @@ -85,6 +105,9 @@ class Source extends Component $this->gitCommitSha = $this->application->git_commit_sha; $this->privateKeyId = $this->application->private_key_id; $this->privateKeyName = data_get($this->application, 'private_key.name'); + $this->gitHttpsUsername = $this->application->git_https_username; + $this->gitHttpsPassword = null; + $this->hasGitHttpsPassword = filled($this->application->git_https_password); } } @@ -138,6 +161,9 @@ class Source extends Component if (str($this->gitCommitSha)->isEmpty()) { $this->gitCommitSha = 'HEAD'; } + if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) { + throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']); + } $this->syncData(true); $this->dispatch('success', 'Application source updated!'); $this->dispatch('configurationChanged'); @@ -146,6 +172,22 @@ class Source extends Component } } + public function removeGitHttpsCredential() + { + try { + $this->authorize('update', $this->application); + $this->application->forceFill([ + 'git_https_username' => null, + 'git_https_password' => null, + ])->save(); + $this->syncData(); + $this->dispatch('success', 'HTTPS credentials removed.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function changeSource($sourceId, $sourceType) { try { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index c48816aa40..0ba40a3bbf 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -361,6 +361,11 @@ class LogDrains extends Component 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index ae3899d60f..824a602355 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -46,7 +46,7 @@ trait BuildsResponse 'clickhouse_admin_password', // app/env secrets - 'value', 'real_value', 'http_basic_auth_password', + 'value', 'real_value', 'http_basic_auth_password', 'git_https_password', // free-form commands / configurations can embed credentials 'git_full_url', diff --git a/app/Models/Application.php b/app/Models/Application.php index 910617f7c7..5183f91237 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -54,6 +54,7 @@ use Symfony\Component\Yaml\Yaml; 'git_branch' => ['type' => 'string', 'description' => 'Git branch.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'Git commit SHA.'], 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], @@ -160,6 +161,8 @@ class Application extends BaseModel 'git_branch', 'git_commit_sha', 'git_full_url', + 'git_https_username', + 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', @@ -262,6 +265,7 @@ class Application extends BaseModel */ protected $hidden = [ 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', @@ -278,6 +282,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'git_https_password' => 'encrypted', 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', @@ -1801,6 +1806,10 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + if ($gitConfigOption) { + $base_command = "git {$gitConfigOption} ls-remote"; + } $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { @@ -1817,6 +1826,53 @@ class Application extends BaseModel } } + /** + * Tells if the application clones a private repository from a custom Git host over HTTP(S) with a username and a password or token. + */ + public function usesGitHttpsCredential(): bool + { + return filled($this->git_https_password) + && $this->deploymentType() === 'other' + && $this->gitHttpsCredentialOrigin() !== null; + } + + /** + * The value of the Basic auth header for the HTTPS credential, also used to redact it from logs. + */ + public function gitHttpsCredentialBasicToken(): ?string + { + if (blank($this->git_https_password)) { + return null; + } + + return base64_encode(($this->git_https_username ?? '').':'.$this->git_https_password); + } + + /** + * A `git -c` option that sends the HTTPS credential as a Basic auth header, only to the host of the repository. + * Git does not save `-c` options in .git/config, and it passes them on to submodule and Git LFS commands. + */ + public function gitHttpsCredentialConfigOption(): ?string + { + if (! $this->usesGitHttpsCredential()) { + return null; + } + + return '-c '.escapeshellarg("http.{$this->gitHttpsCredentialOrigin()}.extraHeader=Authorization: Basic {$this->gitHttpsCredentialBasicToken()}"); + } + + private function gitHttpsCredentialOrigin(): ?string + { + $url = parse_url((string) $this->git_repository); + $scheme = strtolower($url['scheme'] ?? ''); + if (! in_array($scheme, ['http', 'https'], true) || blank($url['host'] ?? null)) { + return null; + } + $port = isset($url['port']) ? ":{$url['port']}" : ''; + + return "{$scheme}://{$url['host']}{$port}/"; + } + private function gitCommand(string $command): array { return [ @@ -2123,8 +2179,11 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + $git_clone_command = str_replace('git clone', "{$gitCommand} clone", $git_clone_command); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { @@ -2135,7 +2194,7 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -2143,14 +2202,14 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOption); } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 81ac70023c..54627e92e8 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -462,6 +462,11 @@ class EnvironmentVariable extends BaseModel } } + if ($resource instanceof Application && filled($resource->git_https_password)) { + $secrets['locked'][] = $resource->git_https_password; + $secrets['locked'][] = $resource->gitHttpsCredentialBasicToken(); + } + $variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables); if ($includePreview && $resource instanceof Application) { $variables = $variables->merge($resource->environment_variables_preview ?? []); diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 4b1255f6e5..55e472020c 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -900,6 +900,7 @@ class ServerTransferExporter // Ensure hidden encrypted fields are included as plaintext. foreach ([ 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 081396b44b..fce0b591a4 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -189,6 +189,8 @@ function sharedDataApplications(?Application $application = null): array return [ 'git_repository' => 'string', 'git_branch' => ['string', new ValidGitBranch], + 'git_https_username' => ['string', 'nullable', 'max:255', 'regex:/^[^:\r\n]*$/'], + 'git_https_password' => ['string', 'nullable', 'max:2048', 'regex:/^[^\r\n]*$/'], 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', 'is_spa' => 'boolean', diff --git a/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php new file mode 100644 index 0000000000..3ce6acfe0e --- /dev/null +++ b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php @@ -0,0 +1,23 @@ +string('git_https_username')->nullable(); + $table->text('git_https_password')->nullable(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn(['git_https_username', 'git_https_password']); + }); + } +}; diff --git a/openapi.json b/openapi.json index 5f720a54ae..39d4602289 100644 --- a/openapi.json +++ b/openapi.json @@ -565,6 +565,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host (not github.com)." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned." + }, "build_pack": { "type": "string", "enum": [ @@ -3639,6 +3649,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. Send null to remove it." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials." + }, "ports_exposes": { "type": "string", "description": "The ports to expose." @@ -24349,6 +24369,11 @@ "nullable": true, "description": "Git full URL." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned." + }, "docker_registry_image_name": { "type": "string", "nullable": true, diff --git a/openapi.yaml b/openapi.yaml index fff4540097..9b18ce4c26 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -355,6 +355,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host (not github.com).' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.' build_pack: type: string enum: [nixpacks, railpack, static, dockerfile, dockercompose] @@ -2407,6 +2415,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.' ports_exposes: type: string description: 'The ports to expose.' @@ -15489,6 +15505,10 @@ components: type: string nullable: true description: 'Git full URL.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.' docker_registry_image_name: type: string nullable: true diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index c2dbc5836b..2c42d25d15 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -42,6 +42,30 @@ :canResource="$application" /> + + @if ($application->deploymentType() === 'other') + + @if ($hasGitHttpsPassword) + @can('update', $application) + + + Remove credentials + + + @endcan + @endif +
+ + +
+
+ @endif @if (filled($privateKeyId)) diff --git a/tests/Feature/GitHttpsCredentialCommandsTest.php b/tests/Feature/GitHttpsCredentialCommandsTest.php new file mode 100644 index 0000000000..36fe3efd82 --- /dev/null +++ b/tests/Feature/GitHttpsCredentialCommandsTest.php @@ -0,0 +1,245 @@ + __DIR__.'/srv', + 'GIT_HTTP_EXPORT_ALL' => '1', + 'GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '', + 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], + 'PATH_INFO' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), + 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', + 'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '', + 'HTTP_CONTENT_ENCODING' => $_SERVER['HTTP_CONTENT_ENCODING'] ?? '', + 'PATH' => getenv('PATH'), + ]); + fwrite($pipes[0], file_get_contents('php://input')); + fclose($pipes[0]); + [$head, $body] = explode("\r\n\r\n", stream_get_contents($pipes[1]), 2) + [1 => '']; + proc_close($backend); + foreach (explode("\r\n", $head) as $line) { + str_starts_with($line, 'Status:') ? http_response_code((int) substr($line, 8)) : header($line); + } + echo $body; + + return true; + PHP); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); + fclose($socket); + + $server = new Process([PHP_BINARY, '-S', "127.0.0.1:{$port}", $root.'/router.php'], $root, ['GIT_TEST_CREDENTIALS' => $credentials]); + $server->start(); + for ($i = 0; $i < 100 && ! @fsockopen('127.0.0.1', $port); $i++) { + usleep(50_000); + } + + return [$server, "http://127.0.0.1:{$port}"]; +} + +/** + * Runs the commands like a server does over SSH: one `bash -se` that reads them from stdin. + */ +function runGitHttpsCredentialCommands(string $root, string $commands): Process +{ + $process = new Process(['bash', '-se'], env: ['PATH' => $root.'/bin:'.getenv('PATH'), 'GIT_TERMINAL_PROMPT' => '0']); + $process->setInput($commands."\n"); + $process->setTimeout(60); + $process->run(); + + return $process; +} + +function gitHttpsCredentialApplication(string $repository, array $attributes = []): Application +{ + $application = Application::factory()->create([ + 'git_repository' => $repository, + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'dockercompose', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + ...$attributes, + ]); + $application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'plain-test-value'])->save(); + $application->settings->update(['is_git_submodules_enabled' => true, 'is_git_lfs_enabled' => false]); + + return $application->fresh(); +} + +function gitHttpsCredentialNonRootServer(): Server +{ + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + + return $server; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + $this->root = sys_get_temp_dir().'/coolify-git-https-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/bin', 0755, true); + mkdir($this->root.'/srv'); + mkdir($this->root.'/work'); + file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + // Runs `docker exec bash -c '...'` on the host, so the helper container commands run unchanged. + file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nshift 2\nexec \"\$@\"\n"); + chmod($this->root.'/bin/sudo', 0755); + chmod($this->root.'/bin/docker', 0755); + + [$this->server, $this->baseUrl] = startGitHttpsCredentialServer($this->root, 'deploy-bot:plain-test-value'); + + $git = fn (string $dir, array $args) => (new Process(['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', '-c', 'protocol.file.allow=always', ...$args], $dir))->mustRun(); + + mkdir($this->root.'/sub'); + file_put_contents($this->root.'/sub/lib.txt', 'submodule file'); + $git($this->root.'/sub', ['init', '-q', '-b', 'main']); + $git($this->root.'/sub', ['add', '-A']); + $git($this->root.'/sub', ['commit', '-qm', 'sub']); + $git($this->root.'/sub', ['clone', '-q', '--bare', '.', $this->root.'/srv/sub.git']); + + mkdir($this->root.'/src'); + $this->compose = "services:\n web:\n image: nginx\n"; + file_put_contents($this->root.'/src/docker-compose.yml', $this->compose); + $git($this->root.'/src', ['init', '-q', '-b', 'main']); + $git($this->root.'/src', ['submodule', 'add', '-q', $this->root.'/srv/sub.git', 'lib']); + $git($this->root.'/src', ['config', '-f', '.gitmodules', 'submodule.lib.url', $this->baseUrl.'/sub.git']); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'app']); + $this->commit = trim($git($this->root.'/src', ['rev-parse', 'HEAD'])->getOutput()); + file_put_contents($this->root.'/src/pr.txt', 'pull request'); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'pr']); + $git($this->root.'/src', ['clone', '-q', '--bare', '.', $this->root.'/srv/app.git']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/pull/7/head', 'HEAD']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/heads/main', $this->commit]); + + $this->repository = $this->baseUrl.'/app.git'; +}); + +afterEach(function () { + $this->server->stop(0); + (new Process(['rm', '-rf', $this->root]))->run(); + Mockery::close(); +}); + +test('the deployment clone uses the HTTPS credential for the repository, the commit, and submodules without saving it', function (int $pullRequestId) { + $application = gitHttpsCredentialApplication($this->repository, ['git_commit_sha' => $pullRequestId === 0 ? $this->commit : 'HEAD']); + $checkout = $this->root.'/work/checkout'; + + ['commands' => $commands] = $application->generateGitImportCommands('deploy-uuid', pull_request_id: $pullRequestId, git_type: 'gitea', exec_in_docker: true, custom_base_dir: $checkout); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); + + $savedConfig = file_get_contents($checkout.'/.git/config').file_get_contents($checkout.'/.git/modules/lib/config'); + expect(file_get_contents($checkout.'/lib/lib.txt'))->toBe('submodule file') + ->and(file_exists($checkout.'/pr.txt'))->toBe($pullRequestId !== 0) + ->and($savedConfig)->not->toContain('plain-test-value') + ->and($savedConfig)->not->toContain($application->gitHttpsCredentialBasicToken()) + ->and($savedConfig)->not->toContain('extraHeader'); +})->with([ + 'pinned commit' => [0], + 'pull request preview' => [7], +]); + +test('git fails without the right HTTPS credential', function () { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill(['git_https_password' => 'wrong-token'])->save(); + + ['commands' => $commands] = $application->fresh()->generateGitImportCommands('deploy-uuid', exec_in_docker: false, custom_base_dir: $this->root.'/work/checkout'); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getErrorOutput())->toContain('could not read Username') + ->and(file_exists($this->root.'/work/checkout/docker-compose.yml'))->toBeFalse(); +}); + +test('ls-remote on the server works for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid', exec_in_docker: false); + $commands = collect([$command]); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toContain("{$this->commit}\trefs/heads/main"); +})->with(['root' => [false], 'non-root' => [true]]); + +test('the deployment job ls-remote uses the HTTPS credential in the helper container', function () { + $application = gitHttpsCredentialApplication($this->repository); + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionObject($job); + foreach (['application' => $application, 'customPort' => 22, 'fullRepoUrl' => $this->repository] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + $command = $reflection->getMethod('gitLsRemoteCommand')->invoke($job, 'refs/heads/main'); + $process = runGitHttpsCredentialCommands($this->root, executeInDocker('deploy-uuid', $command)); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe("{$this->commit}\trefs/heads/main\n"); +}); + +test('the Compose file loads with the HTTPS credential for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + $uuid = 'git-https-'.bin2hex(random_bytes(4)); + $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); + + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid, $gitVersion); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe($this->compose); +})->with(['root' => [false], 'non-root' => [true]]); + +test('git sends the HTTPS credential only to the repository host', function () { + $application = gitHttpsCredentialApplication($this->repository); + $option = $application->gitHttpsCredentialConfigOption(); + + $headerFor = fn (string $url) => trim(runGitHttpsCredentialCommands($this->root, "git {$option} config --get-urlmatch http.extraHeader {$url} || true")->getOutput()); + + expect($headerFor($this->baseUrl.'/other/repo.git'))->toBe('Authorization: Basic '.base64_encode('deploy-bot:plain-test-value')) + ->and($headerFor('http://example.com/app.git'))->toBe('') + ->and($headerFor(str_replace('http://', 'https://', $this->baseUrl).'/app.git'))->toBe(''); +}); + +test('the HTTPS credential is not used for SSH URLs, deploy keys, or applications without a password', function (array $attributes) { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill($attributes)->save(); + + expect($application->fresh()->gitHttpsCredentialConfigOption())->toBeNull(); +})->with([ + 'ssh url' => [['git_repository' => 'git@example.com:org/app.git']], + 'deploy key' => [['private_key_id' => 0]], + 'no password' => [['git_https_password' => null]], +]); diff --git a/tests/Feature/GitHttpsCredentialTest.php b/tests/Feature/GitHttpsCredentialTest.php new file mode 100644 index 0000000000..b1d66d433a --- /dev/null +++ b/tests/Feature/GitHttpsCredentialTest.php @@ -0,0 +1,207 @@ + 'file']); + Storage::fake('ssh-keys'); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); +}); + +function gitHttpsCredentialRawPassword(Application $application): ?string +{ + return DB::table('applications')->where('id', $application->id)->value('git_https_password'); +} + +function gitHttpsCredentialApiToken(User $user, Team $team): string +{ + session(['currentTeam' => $team]); + + return $user->createToken('git-https-credential-test', ['root'])->plainTextToken; +} + +test('an admin saves the HTTPS credential encrypted and the browser never gets the password', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->set('gitHttpsPassword', 'azure-test-value') + ->call('submit') + ->assertSet('gitHttpsPassword', null) + ->assertSet('hasGitHttpsPassword', true); + + $application = $this->application->fresh(); + expect($application->git_https_username)->toBe('deploy-bot') + ->and($application->git_https_password)->toBe('azure-test-value') + ->and(gitHttpsCredentialRawPassword($application))->not->toContain('azure-test-value') + ->and($component->html())->not->toContain('azure-test-value'); + + $component->set('gitHttpsUsername', 'other-bot')->call('submit'); + expect($this->application->fresh()->git_https_password)->toBe('azure-test-value') + ->and($this->application->fresh()->git_https_username)->toBe('other-bot'); + + $component->call('removeGitHttpsCredential')->assertSet('hasGitHttpsPassword', false); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a username without a password is rejected', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->call('submit') + ->assertDispatched('error'); + + expect($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a member of the application team cannot change the HTTPS credential after switching to a team they own', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'member']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($user->id, ['role' => 'owner']); + $this->application->forceFill(['git_https_password' => 'existing-token'])->save(); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]); + session(['currentTeam' => $otherTeam]); + $component->set('gitHttpsPassword', 'attacker-token')->call('submit'); + $component->call('removeGitHttpsCredential'); + + expect($this->application->fresh()->git_https_password)->toBe('existing-token'); +}); + +test('deployment logs never contain the HTTPS credential', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'log-canary-token'])->save(); + $application = $this->application->fresh(); + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => fake()->uuid(), + ]); + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid'); + + $deployment->addLogEntry(message: 'remote said log-canary-token', command: $command); + + $logs = (string) $deployment->fresh()->logs; + expect($command)->toContain($application->gitHttpsCredentialBasicToken()) + ->and($logs)->not->toContain('log-canary-token') + ->and($logs)->not->toContain($application->gitHttpsCredentialBasicToken()); +}); + +test('the API creates an application with an HTTPS credential and never returns the password', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $response = $this->withToken($token)->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://git.example.com/org/private.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'api-created-token', + ])->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + expect($application->git_https_password)->toBe('api-created-token') + ->and($application->gitHttpsCredentialConfigOption())->not->toBeNull(); + + $this->withToken($token)->getJson("/api/v1/applications/{$application->uuid}") + ->assertOk() + ->assertJsonPath('git_https_username', 'deploy-bot') + ->assertJsonMissingPath('git_https_password') + ->assertDontSee('api-created-token'); +}); + +test('the API rejects an HTTPS credential where it is not used', function (string $endpoint, array $payload) { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->postJson($endpoint, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ...$payload, + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); +})->with([ + 'github.com' => ['/api/v1/applications/public', ['git_repository' => 'https://github.com/org/app', 'git_https_password' => 'token']], + 'username only' => ['/api/v1/applications/public', ['git_repository' => 'https://git.example.com/org/app.git', 'git_https_username' => 'deploy-bot']], + 'deploy key endpoint' => ['/api/v1/applications/private-deploy-key', ['git_repository' => 'git@git.example.com:org/app.git', 'git_https_password' => 'token']], +]); + +test('the API updates and removes the HTTPS credential', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'updated-token', + ])->assertOk()->assertDontSee('updated-token'); + expect($this->application->fresh()->git_https_password)->toBe('updated-token'); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => null, + ])->assertOk(); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('the API rejects an HTTPS credential for an application with a deploy key', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + $this->application->forceFill(['private_key_id' => 0])->save(); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'token', + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); + +test('a token of another team cannot set the HTTPS credential', function () { + $otherUser = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + $token = gitHttpsCredentialApiToken($otherUser, $otherTeam); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'attacker-token', + ])->assertNotFound(); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index a5d97bdb98..21fb1ce86b 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -291,6 +291,7 @@ describe('deployment git command escaping', function () { $instance = $job->newInstanceWithoutConstructor(); foreach ([ + 'application' => new Application, 'customPort' => 22, 'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #", ] as $property => $value) { diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 2e9610d31f..ac985e1eda 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -262,6 +262,7 @@ test('dry run import reports counts without creating server', function () { }); test('round trip import preserves uuids secrets and related resources after source removal', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'transfer-pat-secret'])->save(); $bundle = $this->exporter->export($this->server); $originalServerUuid = $this->server->uuid; $originalAppUuid = $this->application->uuid; @@ -309,7 +310,9 @@ test('round trip import preserves uuids secrets and related resources after sour ->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value') ->and($app->persistentStorages)->toHaveCount(1) ->and($app->scheduled_tasks)->toHaveCount(1) - ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo'); + ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo') + ->and($app->git_https_username)->toBe('deploy-bot') + ->and($app->git_https_password)->toBe('transfer-pat-secret'); $db = StandalonePostgresql::where('uuid', $originalDbUuid)->first(); expect($db)->not->toBeNull() diff --git a/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php new file mode 100644 index 0000000000..034a4a43c2 --- /dev/null +++ b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php @@ -0,0 +1,33 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('saves the HTTPS credential from the source page without showing the token again', function () { + $stack = seedBrowserResourceStack(); + $application = createBrowserApplication($stack, [ + 'name' => 'Azure App', + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application).'/source'); + $page->assertSee('HTTPS credentials') + ->type('gitHttpsUsername', 'deploy-bot') + ->type('gitHttpsPassword', 'browser-pat-token') + ->click('button:visible:has-text("Save changes")') + ->assertSee('Remove credentials') + ->assertValue('input[name=gitHttpsPassword]', '') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-git-https-credential'); + + expect($application->fresh()->git_https_password)->toBe('browser-pat-token') + ->and($application->fresh()->git_https_username)->toBe('deploy-bot'); +});