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:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-27 21:19:20 +01:00
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',
+16 -18
View File
@@ -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;
}
+4 -3
View File
@@ -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;
+19
View File
@@ -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-');
});