mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
fix(auth): limit application commands and configuration to admins and read:sensitive tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
efbd64346c
commit
eb030c6a22
6 files changed
+258
-23
No files matched your search
@@ -126,6 +126,7 @@ class ApplicationsController extends Controller
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$application->makeVisible([
|
||||
...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES,
|
||||
'custom_labels',
|
||||
'dockerfile',
|
||||
'docker_compose',
|
||||
|
||||
@@ -460,16 +460,17 @@ class General extends Component
|
||||
|
||||
$this->saveApplicationSettingsWithAudit($this->application);
|
||||
} else {
|
||||
// From model to properties
|
||||
// From model to properties. Commands, configuration, and secrets can embed credentials, so only users who can update the application get them.
|
||||
$canViewSecrets = auth()->user()?->can('update', $this->application) ?? false;
|
||||
$this->name = $this->application->name;
|
||||
$this->description = $this->application->description;
|
||||
$this->fqdn = $this->application->fqdn;
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->gitBranch = $this->application->git_branch;
|
||||
$this->gitCommitSha = $this->application->git_commit_sha;
|
||||
$this->installCommand = $this->application->install_command;
|
||||
$this->buildCommand = $this->application->build_command;
|
||||
$this->startCommand = $this->application->start_command;
|
||||
$this->installCommand = $canViewSecrets ? $this->application->install_command : null;
|
||||
$this->buildCommand = $canViewSecrets ? $this->application->build_command : null;
|
||||
$this->startCommand = $canViewSecrets ? $this->application->start_command : null;
|
||||
$this->buildPack = $this->application->build_pack;
|
||||
$this->staticImage = $this->application->static_image;
|
||||
$this->baseDirectory = $this->application->base_directory;
|
||||
@@ -477,29 +478,26 @@ class General extends Component
|
||||
$this->portsExposes = $this->application->ports_exposes;
|
||||
$this->portsMappings = $this->application->ports_mappings;
|
||||
$this->customNetworkAliases = $this->application->custom_network_aliases;
|
||||
$this->dockerfile = $this->application->dockerfile;
|
||||
$this->dockerfile = $canViewSecrets ? $this->application->dockerfile : null;
|
||||
$this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null);
|
||||
$this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
|
||||
$this->dockerRegistryImageName = $this->application->docker_registry_image_name;
|
||||
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
|
||||
$this->dockerComposeLocation = $this->application->docker_compose_location;
|
||||
$canViewCompose = auth()->user()?->can('update', $this->application) ?? false;
|
||||
$this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null;
|
||||
$this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null;
|
||||
$this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
|
||||
$this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
|
||||
$this->customLabels = $this->application->parseContainerLabels();
|
||||
$this->customDockerRunOptions = $this->application->custom_docker_run_options;
|
||||
$this->preDeploymentCommand = $this->application->pre_deployment_command;
|
||||
$this->dockerCompose = $canViewSecrets ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null;
|
||||
$this->dockerComposeRaw = $canViewSecrets ? $this->application->docker_compose_raw : null;
|
||||
$this->dockerComposeCustomStartCommand = $canViewSecrets ? $this->application->docker_compose_custom_start_command : null;
|
||||
$this->dockerComposeCustomBuildCommand = $canViewSecrets ? $this->application->docker_compose_custom_build_command : null;
|
||||
$this->customLabels = $canViewSecrets ? $this->application->parseContainerLabels() : null;
|
||||
$this->customDockerRunOptions = $canViewSecrets ? $this->application->custom_docker_run_options : null;
|
||||
$this->preDeploymentCommand = $canViewSecrets ? $this->application->pre_deployment_command : null;
|
||||
$this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container;
|
||||
$this->postDeploymentCommand = $this->application->post_deployment_command;
|
||||
$this->postDeploymentCommand = $canViewSecrets ? $this->application->post_deployment_command : null;
|
||||
$this->postDeploymentCommandContainer = $this->application->post_deployment_command_container;
|
||||
$this->customNginxConfiguration = $this->application->custom_nginx_configuration;
|
||||
$this->customNginxConfiguration = $canViewSecrets ? $this->application->custom_nginx_configuration : null;
|
||||
$this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
|
||||
$this->httpBasicAuthUsername = $this->application->http_basic_auth_username;
|
||||
$this->httpBasicAuthPassword = auth()->user()->can('update', $this->application)
|
||||
? $this->application->http_basic_auth_password
|
||||
: null;
|
||||
$this->httpBasicAuthPassword = $canViewSecrets ? $this->application->http_basic_auth_password : null;
|
||||
$this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value;
|
||||
$this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost;
|
||||
$this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost;
|
||||
|
||||
@@ -53,7 +53,7 @@ class ConfigurationChecker extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Members must never see environment variable values or Compose content, so redact
|
||||
* Members must never see environment variable values, Compose content, commands, or configuration, so redact
|
||||
* those changes before they are serialized to the browser.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
@@ -67,8 +67,9 @@ class ConfigurationChecker extends Component
|
||||
|
||||
return collect($changes)
|
||||
->map(function (array $change): array {
|
||||
$key = str((string) data_get($change, 'key'))->afterLast('.')->value();
|
||||
$isHidden = data_get($change, 'section') === 'environment'
|
||||
|| str((string) data_get($change, 'key'))->afterLast('.')->value() === 'docker_compose_raw';
|
||||
|| in_array($key, [...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'docker_compose_raw', 'dockerfile', 'custom_labels'], true);
|
||||
if (! $isHidden) {
|
||||
return $change;
|
||||
}
|
||||
|
||||
@@ -118,17 +118,18 @@ class HealthChecks extends Component
|
||||
|
||||
$this->resource->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
// Sync from model. The command and response text can embed credentials, so only users who can update the resource get them.
|
||||
$canViewSecrets = auth()->user()?->can('update', $this->resource) ?? false;
|
||||
$this->healthCheckEnabled = $this->resource->health_check_enabled;
|
||||
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
|
||||
$this->healthCheckCommand = $this->resource->health_check_command;
|
||||
$this->healthCheckCommand = $canViewSecrets ? $this->resource->health_check_command : null;
|
||||
$this->healthCheckMethod = $this->resource->health_check_method;
|
||||
$this->healthCheckScheme = $this->resource->health_check_scheme;
|
||||
$this->healthCheckHost = $this->resource->health_check_host;
|
||||
$this->healthCheckPort = $this->resource->health_check_port;
|
||||
$this->healthCheckPath = $this->resource->health_check_path;
|
||||
$this->healthCheckReturnCode = $this->resource->health_check_return_code;
|
||||
$this->healthCheckResponseText = $this->resource->health_check_response_text;
|
||||
$this->healthCheckResponseText = $canViewSecrets ? $this->resource->health_check_response_text : null;
|
||||
$this->healthCheckInterval = $this->resource->health_check_interval;
|
||||
$this->healthCheckTimeout = $this->resource->health_check_timeout;
|
||||
$this->healthCheckRetries = $this->resource->health_check_retries;
|
||||
|
||||
@@ -254,6 +254,24 @@ class Application extends BaseModel
|
||||
|
||||
protected $appends = ['server_status'];
|
||||
|
||||
/**
|
||||
* Free-form commands and configuration that can embed credentials. Only users who can
|
||||
* update the application and `read:sensitive` tokens may read them.
|
||||
*/
|
||||
public const COMMAND_AND_CONFIGURATION_ATTRIBUTES = [
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'pre_deployment_command',
|
||||
'post_deployment_command',
|
||||
'health_check_command',
|
||||
'health_check_response_text',
|
||||
'custom_docker_run_options',
|
||||
'docker_compose_custom_start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'custom_nginx_configuration',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -261,6 +279,7 @@ class Application extends BaseModel
|
||||
* job, compose generation) must makeVisible explicitly before toArray().
|
||||
*/
|
||||
protected $hidden = [
|
||||
...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES,
|
||||
'http_basic_auth_password',
|
||||
'manual_webhook_secret_github',
|
||||
'manual_webhook_secret_gitlab',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Livewire\Project\Shared\HealthChecks;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
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 Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const APP_SECRET_COLUMNS = [
|
||||
'install_command' => 'installCommand',
|
||||
'build_command' => 'buildCommand',
|
||||
'start_command' => 'startCommand',
|
||||
'pre_deployment_command' => 'preDeploymentCommand',
|
||||
'post_deployment_command' => 'postDeploymentCommand',
|
||||
'custom_docker_run_options' => 'customDockerRunOptions',
|
||||
'docker_compose_custom_start_command' => 'dockerComposeCustomStartCommand',
|
||||
'docker_compose_custom_build_command' => 'dockerComposeCustomBuildCommand',
|
||||
'custom_nginx_configuration' => 'customNginxConfiguration',
|
||||
'dockerfile' => 'dockerfile',
|
||||
];
|
||||
|
||||
const APP_SECRET_HEALTH_CHECK_COLUMNS = [
|
||||
'health_check_command' => 'healthCheckCommand',
|
||||
'health_check_response_text' => 'healthCheckResponseText',
|
||||
];
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->member = User::factory()->create();
|
||||
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
$this->team->members()->attach($this->member->id, ['role' => 'member']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$this->application->forceFill(applicationSecretValues())->save();
|
||||
$this->application = $this->application->fresh();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function applicationSecretValues(): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS)] as $column) {
|
||||
$values[$column] = "secret-{$column}-value";
|
||||
}
|
||||
$values['custom_labels'] = base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-custom_labels-value');
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
function expectApplicationSecretsAbsent(string $text): void
|
||||
{
|
||||
foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS), 'custom_labels'] as $column) {
|
||||
expect($text)->not->toContain("secret-{$column}-value");
|
||||
}
|
||||
}
|
||||
|
||||
function expectApplicationSecretsUnchanged(Application $application): void
|
||||
{
|
||||
$stored = $application->fresh();
|
||||
foreach (applicationSecretValues() as $column => $value) {
|
||||
expect($stored->getAttribute($column))->toBe($value);
|
||||
}
|
||||
}
|
||||
|
||||
function applicationApiToken(User $user, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => test()->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
it('keeps application commands, Dockerfile, and labels out of member Livewire state', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$general = Livewire::test(General::class, ['application' => $this->application]);
|
||||
foreach (APP_SECRET_COLUMNS as $property) {
|
||||
$general->assertSet($property, null);
|
||||
}
|
||||
$general->assertSet('customLabels', null);
|
||||
|
||||
$healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]);
|
||||
foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $property) {
|
||||
$healthChecks->assertSet($property, null);
|
||||
}
|
||||
|
||||
expectApplicationSecretsAbsent(
|
||||
json_encode($general->snapshot).$general->html().json_encode($healthChecks->snapshot).$healthChecks->html()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps application commands, Dockerfile, and labels available to an owner', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$general = Livewire::test(General::class, ['application' => $this->application]);
|
||||
foreach (APP_SECRET_COLUMNS as $column => $property) {
|
||||
$general->assertSet($property, "secret-{$column}-value");
|
||||
}
|
||||
expect($general->get('customLabels'))->toContain('secret-custom_labels-value');
|
||||
|
||||
$healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]);
|
||||
foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $column => $property) {
|
||||
$healthChecks->assertSet($property, "secret-{$column}-value");
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a member save clear the hidden values', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
foreach (['submit', 'instantSave'] as $method) {
|
||||
Livewire::test(General::class, ['application' => $this->application])->call($method);
|
||||
}
|
||||
foreach (['submit', 'instantSave', 'toggleHealthcheck'] as $method) {
|
||||
Livewire::test(HealthChecks::class, ['resource' => $this->application])->call($method);
|
||||
}
|
||||
|
||||
expectApplicationSecretsUnchanged($this->application);
|
||||
});
|
||||
|
||||
it('keeps an owner save from changing the values', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(HealthChecks::class, ['resource' => $this->application])->call('submit');
|
||||
|
||||
expectApplicationSecretsUnchanged($this->application);
|
||||
});
|
||||
|
||||
it('hides application commands and configuration from a read token', function (string $path) {
|
||||
$response = $this->withToken(applicationApiToken($this->member, ['read']))
|
||||
->getJson(str_replace('{uuid}', $this->application->uuid, $path))
|
||||
->assertOk();
|
||||
|
||||
expectApplicationSecretsAbsent($response->getContent());
|
||||
})->with([
|
||||
'application' => '/api/v1/applications/{uuid}',
|
||||
'application list' => '/api/v1/applications',
|
||||
'resources' => '/api/v1/resources',
|
||||
]);
|
||||
|
||||
it('shows application commands and configuration to a read:sensitive token', function (string $path) {
|
||||
$response = $this->withToken(applicationApiToken($this->owner, ['read', 'read:sensitive']))
|
||||
->getJson(str_replace('{uuid}', $this->application->uuid, $path))
|
||||
->assertOk();
|
||||
|
||||
foreach (applicationSecretValues() as $value) {
|
||||
expect($response->getContent())->toContain(str_replace('/', '\/', $value));
|
||||
}
|
||||
})->with([
|
||||
'application' => '/api/v1/applications/{uuid}',
|
||||
'application list' => '/api/v1/applications',
|
||||
'resources' => '/api/v1/resources',
|
||||
]);
|
||||
|
||||
it('redacts command and configuration changes for members in the pending change list', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $this->application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
$this->application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$changed = collect(applicationSecretValues())->map(fn (string $value, string $column) => $column === 'custom_labels'
|
||||
? base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-new-custom_labels-value')
|
||||
: str_replace('secret-', 'secret-new-', $value))->all();
|
||||
$this->application->forceFill($changed)->save();
|
||||
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $this->application->refresh()]);
|
||||
|
||||
expect($component->get('isConfigurationChanged'))->toBeTrue();
|
||||
$text = json_encode($component->snapshot).$component->html();
|
||||
expectApplicationSecretsAbsent($text);
|
||||
expect($text)->not->toContain('secret-new-');
|
||||
});
|
||||
Reference in new issue
Block a user