chore: merge main into next (#12354)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-28 12:27:08 +01:00
committed by GitHub
co-authored by Aditya Tripathi Claude Opus 5.5
42 changed files with 709 additions and 166 deletions
@@ -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',
@@ -161,21 +162,6 @@ class ApplicationsController extends Controller
return $payload;
}
/**
* Setting or changing a manual webhook secret needs the deploy ability. Sending the stored value
* again or clearing it does not.
*/
private function manualWebhookSecretForbiddenResponse(Request $request, ?Application $application = null): ?JsonResponse
{
foreach (['manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea'] as $field) {
if ($request->filled($field) && $request->input($field) !== $application?->{$field}) {
return $this->deployAbilityForbiddenResponse($request, 'A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
}
}
return null;
}
private function applicationSettingsFromRequest(Request $request): array
{
$settings = [];
@@ -1545,9 +1531,6 @@ class ApplicationsController extends Controller
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
return $internalNameValidation;
}
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request)) {
return $forbidden;
}
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
@@ -3526,10 +3509,6 @@ class ApplicationsController extends Controller
'errors' => $errors,
], 422);
}
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request, $application)) {
return $forbidden;
}
$githubApp = null;
if ($request->has('github_app_uuid')) {
if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') {
@@ -251,6 +251,7 @@ class DatabasesController extends Controller
'mysql_root_password',
'mariadb_password',
'mariadb_root_password',
...$database::CONFIGURATION_ATTRIBUTES,
]);
$this->exposeNestedServerSecrets($database);
} else {
+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;
@@ -137,17 +137,17 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->clickhouseAdminUser = $this->database->clickhouse_admin_user;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->clickhouseAdminPassword = $canSeeCredentials ? $this->database->clickhouse_admin_password : '';
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
}
}
@@ -124,16 +124,16 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->dragonflyPassword = $canSeeCredentials ? $this->database->dragonfly_password : '';
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
}
}
@@ -128,9 +128,9 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->keydbConf = $canSeeCredentials ? $this->database->keydb_conf : null;
$this->keydbPassword = $canSeeCredentials ? $this->database->keydb_password : '';
$this->image = $this->database->image;
@@ -138,7 +138,7 @@ class General extends Component
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
}
}
@@ -153,9 +153,9 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->mariadbRootPassword = $canSeeCredentials ? $this->database->mariadb_root_password : '';
$this->mariadbUser = $this->database->mariadb_user;
$this->mariadbPassword = $canSeeCredentials ? $this->database->mariadb_password : '';
@@ -167,7 +167,7 @@ class General extends Component
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
}
}
@@ -145,9 +145,9 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->mongoConf = $canSeeCredentials ? $this->database->mongo_conf : null;
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
$this->mongoInitdbRootPassword = $canSeeCredentials ? $this->database->mongo_initdb_root_password : '';
@@ -158,7 +158,7 @@ class General extends Component
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
}
}
@@ -153,9 +153,9 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->mysqlRootPassword = $canSeeCredentials ? $this->database->mysql_root_password : '';
$this->mysqlUser = $this->database->mysql_user;
$this->mysqlPassword = $canSeeCredentials ? $this->database->mysql_password : '';
@@ -167,7 +167,7 @@ class General extends Component
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
}
}
@@ -169,10 +169,10 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgresUser = $this->database->postgres_user;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->postgresPassword = $canSeeCredentials ? $this->database->postgres_password : '';
$this->postgresDb = $this->database->postgres_db;
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
@@ -185,7 +185,7 @@ class General extends Component
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
}
}
@@ -146,6 +146,7 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->image = $this->database->image;
@@ -154,10 +155,9 @@ class General extends Component
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->redisConf = $canSeeCredentials ? $this->database->redis_conf : null;
$this->isPasswordLocked = $this->database->isCredentialLocked();
$this->redisPassword = $canSeeCredentials && ! $this->isPasswordLocked ? $this->database->redis_password : null;
@@ -29,6 +29,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function mount()
{
try {
@@ -43,6 +45,8 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
protected function rules(): array
@@ -83,11 +87,12 @@ class General extends Component
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->sqliteDatabases = $this->database->sqlite_databases;
$this->image = $this->database->image;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
}
}
@@ -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
@@ -258,6 +258,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
@@ -265,6 +283,7 @@ class Application extends BaseModel
* job, compose generation) must makeVisible explicitly before toArray().
*/
protected $hidden = [
...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES,
'http_basic_auth_password',
'git_https_password',
'manual_webhook_secret_github',
+9
View File
@@ -63,6 +63,14 @@ class StandaloneClickhouse extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -70,6 +78,7 @@ class StandaloneClickhouse extends BaseModel
*/
protected $hidden = [
'clickhouse_admin_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
];
+9
View File
@@ -57,6 +57,14 @@ class StandaloneDragonfly extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -64,6 +72,7 @@ class StandaloneDragonfly extends BaseModel
*/
protected $hidden = [
'dragonfly_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
// Internal start-command state, not part of the API.
+10
View File
@@ -58,6 +58,15 @@ class StandaloneKeydb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'keydb_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -65,6 +74,7 @@ class StandaloneKeydb extends BaseModel
*/
protected $hidden = [
'keydb_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
// Internal start-command state, not part of the API.
+10
View File
@@ -61,6 +61,15 @@ class StandaloneMariadb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'mariadb_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -69,6 +78,7 @@ class StandaloneMariadb extends BaseModel
protected $hidden = [
'mariadb_password',
'mariadb_root_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
];
+10
View File
@@ -61,6 +61,15 @@ class StandaloneMongodb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'mongo_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -68,6 +77,7 @@ class StandaloneMongodb extends BaseModel
*/
protected $hidden = [
'mongo_initdb_root_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
];
+10
View File
@@ -62,6 +62,15 @@ class StandaloneMysql extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'mysql_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -70,6 +79,7 @@ class StandaloneMysql extends BaseModel
protected $hidden = [
'mysql_password',
'mysql_root_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
];
+10
View File
@@ -64,6 +64,15 @@ class StandalonePostgresql extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'postgres_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -72,6 +81,7 @@ class StandalonePostgresql extends BaseModel
protected $hidden = [
'postgres_password',
'init_scripts',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
];
+10
View File
@@ -59,6 +59,15 @@ class StandaloneRedis extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'redis_conf',
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -66,6 +75,7 @@ class StandaloneRedis extends BaseModel
*/
protected $hidden = [
'redis_password',
...self::CONFIGURATION_ATTRIBUTES,
'internal_db_url',
'external_db_url',
// Internal start-command state, not part of the API.
+17
View File
@@ -58,6 +58,23 @@ class StandaloneSqlite extends BaseModel
'health_check_start_period',
];
/**
* Free-form configuration that can embed credentials. Only users who can
* update the database and `read:sensitive` tokens may read it.
*/
public const CONFIGURATION_ATTRIBUTES = [
'custom_docker_run_options',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
...self::CONFIGURATION_ATTRIBUTES,
];
protected $appends = ['database_type', 'server_status'];
protected $casts = [
@@ -2,8 +2,6 @@
namespace App\Notifications\Dto;
use Illuminate\Support\Facades\Log;
class PushoverMessage
{
public function __construct(
@@ -43,8 +41,6 @@ class PushoverMessage
$payload['message'] .= "&nbsp;<a href='".$buttonUrl."'>".$text.'</a>';
}
Log::info('Pushover message', $payload);
return $payload;
}
}
@@ -54,6 +54,17 @@ tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(databa
!skip { print }
AWK;
/**
* mysql and mariadb client commands that run a shell command, write or read a file:
* system, tee, pager, edit and source, as a long command at the start of a line or as
* a short command (\!, \T, \P, \e, \.) anywhere. An odd backslash run starts a short
* command; mysqldump writes a backslash in data as "\\" and escapes only 0, n, r, Z and
* quotes, so its rows do not match.
*/
private const MYSQL_CLIENT_COMMAND_PATTERN = '(^|[^\\\\])(\\\\\\\\)*\\\\[!TPe.]';
private const MYSQL_NAMED_CLIENT_COMMAND_PATTERN = '^[[:space:]]*(system|tee|pager|edit|source)([[:space:]]|;|$)';
/**
* Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks.
*/
@@ -317,24 +328,42 @@ SH;
* MySQL and MariaDB restore SQL dumps. A tar backup must wrap exactly one dump.
* The all-databases mode checks the backup before it drops any database and skips
* the system databases unless their users and privileges are restored as well.
* The client runs the dump with its client commands disabled: --sandbox on MariaDB
* (10.5.25, 10.6.18, 10.11.8, 11.4.2 and later), --commands=FALSE on MySQL (8.0.43, 8.4.6,
* 9.4.0 and later). Older clients do not know these options, so a dump with a client
* command that runs a shell command or uses a file is refused on every client.
*/
private function mysql(string $binary, string $prefix, bool $dumpAll, bool $restoreMysqlUsers = false): string
{
$preflight = <<<'SH'
$commandPattern = escapeshellarg(self::MYSQL_CLIENT_COMMAND_PATTERN);
$namedCommandPattern = escapeshellarg(self::MYSQL_NAMED_CLIENT_COMMAND_PATTERN);
$preflight = <<<SH
if is_tar; then
extract_tar
use_single_tar_member || fail 'A tar backup must contain exactly one SQL dump. Nothing was changed.'
fi
is_text || fail 'Unsupported backup format. Use an SQL dump. Nothing was changed.'
if stream | LC_ALL=C grep -Eq {$commandPattern} || stream | LC_ALL=C grep -Eiq {$namedCommandPattern}; then
fail 'Blocked restore: the backup contains a {$binary} client command (system, tee, pager, edit or source). If you trust this dump, restore it from the database container terminal. Nothing was changed.'
fi
SH;
$clientSafety = <<<SH
client_safety=
if {$binary} --help 2>/dev/null | grep -q -e '--sandbox'; then
client_safety=--sandbox
elif {$binary} --help 2>/dev/null | grep -q -e '--commands'; then
client_safety=--commands=FALSE
fi
SH;
if (! $dumpAll) {
return $preflight.<<<SH
return $preflight.$clientSafety.<<<SH
if [ "$(stream | grep -c '^USE `')" -gt 1 ]; then
fail 'This backup contains more than one database. Select "Backup contains all databases" to restore it. Nothing was changed.'
fi
stream | {$binary} -u \${$prefix}_USER -p\${$prefix}_PASSWORD \${$prefix}_DATABASE
stream | {$binary} \$client_safety -u \${$prefix}_USER -p\${$prefix}_PASSWORD \${$prefix}_DATABASE
SH;
}
@@ -350,11 +379,11 @@ SH;
$source = '{ stream | awk '.escapeshellarg(self::MYSQL_SYSTEM_DATABASE_FILTER)." || echo 'Coolify could not filter the system databases of the backup;'; }";
}
return $preflight.<<<SH
return $preflight.$clientSafety.<<<SH
for pid in \$({$root} -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do {$root} -e "KILL \$pid" 2>/dev/null || true; done
{$root} -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | {$root} || exit 1
{$root} -e "CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;" || exit 1
{$source} | {$root} {$database}
{$source} | {$binary} \$client_safety -u root -p\${{$prefix}_ROOT_PASSWORD} {$database}
SH;
}
@@ -104,11 +104,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
@@ -82,11 +82,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
@@ -83,11 +83,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the KeyDB directives you need. All other defaults remain active.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom KeyDB configuration" disabled value="Hidden (only admins can view)" />
@@ -88,11 +88,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MariaDB configuration used by this container.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MariaDB configuration" disabled value="Hidden (only admins can view)" />
@@ -85,11 +85,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MongoDB configuration used by this container.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MongoDB configuration" disabled value="Hidden (only admins can view)" />
@@ -88,11 +88,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MySQL configuration used by this container.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MySQL configuration" disabled value="Hidden (only admins can view)" />
@@ -90,11 +90,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the PostgreSQL configuration used by this container.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom PostgreSQL configuration" disabled value="Hidden (only admins can view)" />
@@ -90,11 +90,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options, configuration, and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the Redis directives you need. All other defaults remain active.">
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Redis configuration" disabled value="Hidden (only admins can view)" />
@@ -31,11 +31,15 @@
<x-application.settings-section id="container-section" title="Container"
description="Docker options and log forwarding for the database container.">
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
@endif
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
@@ -50,51 +50,22 @@ function manualWebhookSecretAbilityHeaders(User $user, int $teamId, array $abili
}
describe('update', function () {
test('write-only token cannot set a new manual webhook secret', function (string $field) {
test('write-only token can set a new manual webhook secret', function (string $field) {
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'attacker-secret-0123456789'])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'new-secret-0123456789'])
->assertOk();
$application = $this->application->fresh();
expect($application->manual_webhook_secret_github)->toBe('existing-secret-0123456789');
expect($application->{$field})->not->toBe('attacker-secret-0123456789');
expect($this->application->fresh()->{$field})->toBe('new-secret-0123456789');
})->with([
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
]);
test('write-only token can update other fields and send the stored secret back', function () {
$headers = manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$this->application->uuid}", ['description' => 'changed'])
->assertOk();
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'description' => 'changed again',
'manual_webhook_secret_github' => 'existing-secret-0123456789',
])
->assertOk();
expect($this->application->fresh())
->description->toBe('changed again')
->manual_webhook_secret_github->toBe('existing-secret-0123456789');
});
test('write and deploy token can set a new manual webhook secret', function () {
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
->patchJson("/api/v1/applications/{$this->application->uuid}", ['manual_webhook_secret_gitlab' => 'new-gitlab-secret-0123456789'])
->assertOk();
expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('new-gitlab-secret-0123456789');
});
});
describe('create', function () {
test('write-only token cannot create an application with a manual webhook secret', function () {
test('write-only token can create an application with a manual webhook secret', function () {
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
@@ -102,11 +73,10 @@ describe('create', function () {
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'ports_exposes' => '80',
'manual_webhook_secret_github' => 'attacker-secret-0123456789',
'manual_webhook_secret_github' => 'new-secret-0123456789',
])
->assertForbidden()
->assertJsonPath('message', fn (string $message) => str_contains($message, 'manual webhook secret'));
->assertCreated();
expect(Application::count())->toBe(1);
expect(Application::latest('id')->first()->manual_webhook_secret_github)->toBe('new-secret-0123456789');
});
});
@@ -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-');
});
@@ -1,5 +1,7 @@
<?php
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
@@ -9,6 +11,7 @@ use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Livewire\Project\Database\Sqlite\General as SqliteGeneral;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
@@ -239,3 +242,129 @@ it('still returns the connection url of an unlocked redis password in the api',
->assertOk()
->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0");
});
dataset('database-api-config', [
'postgresql' => ['create_standalone_postgresql', 'postgresqls', ['postgres_conf' => "primary_conninfo = 'password=secret-config-value'"]],
'mysql' => ['create_standalone_mysql', 'mysqls', ['mysql_conf' => 'password=secret-config-value']],
'mariadb' => ['create_standalone_mariadb', 'mariadbs', ['mariadb_conf' => 'password=secret-config-value']],
'mongodb' => ['create_standalone_mongodb', 'mongodbs', ['mongo_conf' => 'keyFile: secret-config-value']],
'redis' => ['create_standalone_redis', 'redis', ['redis_conf' => 'masterauth secret-config-value']],
'keydb' => ['create_standalone_keydb', null, ['keydb_conf' => 'masterauth secret-config-value']],
'dragonfly' => ['create_standalone_dragonfly', null, []],
'clickhouse' => ['create_standalone_clickhouse', null, []],
]);
function apiTokenHeaders(User $user, Team $team, array $abilities): array
{
$token = $user->createToken('config-test', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]);
return ['Authorization' => 'Bearer '.$token->plainTextToken];
}
it('hides custom configs and docker run options from a token without sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
$database = createDatabaseWithConfig($createDatabase, [
...$config,
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
]);
session(['currentTeam' => $this->team]);
$project = $this->environment->project;
foreach ([
[$this->member, ['read']],
[$this->member, ['read', 'read:sensitive']],
[$this->owner, ['read']],
] as [$user, $abilities]) {
$headers = apiTokenHeaders($user, $this->team, $abilities);
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk();
expect($single->json('uuid'))->toBe($database->uuid)
->and($single->getContent())->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
expect($list->getContent())->toContain($database->uuid)
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
if ($environmentRelation !== null) {
$environment = $this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk();
expect($environment->getContent())->toContain($database->uuid)
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
}
}
})->with('database-api-config');
it('returns custom configs and docker run options to an owner token with sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
$database = createDatabaseWithConfig($createDatabase, [
...$config,
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
]);
session(['currentTeam' => $this->team]);
$headers = apiTokenHeaders($this->owner, $this->team, ['read', 'read:sensitive']);
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk()
->assertJsonPath('custom_docker_run_options', '--env API_TOKEN=secret-docker-value');
foreach ($config as $key => $value) {
$single->assertJsonPath($key, $value);
}
if ($environmentRelation !== null) {
$project = $this->environment->project;
$this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk()
->assertJsonPath("{$environmentRelation}.0.custom_docker_run_options", '--env API_TOKEN=secret-docker-value');
}
})->with('database-api-config');
dataset('database-docker-options-components', [
'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class],
'mysql' => ['create_standalone_mysql', MysqlGeneral::class],
'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class],
'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class],
'redis' => ['create_standalone_redis', RedisGeneral::class],
'keydb' => ['create_standalone_keydb', KeydbGeneral::class],
'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class],
'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class],
'sqlite' => ['create_standalone_sqlite', SqliteGeneral::class],
]);
it('keeps custom docker run options out of member state and html', function (string $createDatabase, string $generalClass) {
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
$ownTeam = Team::factory()->create();
$ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
$this->actingAs($this->member);
foreach ([$this->team, $ownTeam] as $sessionTeam) {
session(['currentTeam' => $sessionTeam]);
$general = Livewire::test($generalClass, ['database' => $database])
->assertSet('customDockerRunOptions', null);
expect(json_encode($general->snapshot))->not->toContain('secret-docker-value')
->and($general->html())->not->toContain('secret-docker-value')
->and($general->html())->toContain('Hidden (only admins can view)');
}
})->with('database-docker-options-components');
it('keeps custom docker run options available to an owner', function (string $createDatabase, string $generalClass) {
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test($generalClass, ['database' => $database])
->assertSet('customDockerRunOptions', '--env API_TOKEN=secret-docker-value');
})->with('database-docker-options-components');
it('does not let a member save over hidden custom configs and docker run options', function (string $createDatabase, string $generalClass) {
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test($generalClass, ['database' => $database])
->set('name', 'renamed-by-member')
->call('submit')
->call('instantSaveAdvanced');
$database = $database->fresh();
expect($database->name)->not->toBe('renamed-by-member')
->and($database->custom_docker_run_options)->toBe('--env API_TOKEN=secret-docker-value');
})->with('database-docker-options-components');
@@ -31,13 +31,13 @@ test('builds database-specific restore commands', function (string $class, ?stri
expect($command)->toContain($needle)->toContain("'/tmp/restore file'");
})->with([
'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'],
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'],
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'],
'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER'],
'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER'],
'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'],
'sqlite' => [StandaloneSqlite::class, null, '.restore'],
'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'],
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'],
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'],
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER'],
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER'],
'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'],
]);
@@ -51,10 +51,10 @@ test('decompresses gzip backups for single-database mysql and mariadb restores',
->toContain('is_gzip() { [ "$(head -c 2 "$backup" | od -An -tx1 | tr -d \' \n\')" = 1f8b ]; }')
->toEndWith("stream | {$client}");
})->with([
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
]);
test('builds dump-all commands and postgres safety scan', function () {
@@ -158,7 +158,7 @@ test('skips the system databases of MySQL and MariaDB all-databases backups by d
expect($command)->toContain('command -v awk')
->toMatch('/stream \| \{?\s*awk /')
->toContain('"mysql"')->toContain('"sys"')->toContain('"performance_schema"')->toContain('"information_schema"')
->toContain("| {$binary} -u root");
->toContain("| {$binary} \$client_safety -u root");
})->with([
'mysql' => [StandaloneMysql::class, 'mysql'],
'mariadb' => [StandaloneMariadb::class, 'mariadb'],
@@ -168,7 +168,7 @@ test('restores the system databases of MySQL and MariaDB all-databases backups w
$command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource($class), '/tmp/backup.sql', true, restoreMysqlUsers: true);
expect($command)->not->toContain('awk')
->toContain("stream | {$binary} -u root");
->toContain("stream | {$binary} \$client_safety -u root");
})->with([
'mysql' => [StandaloneMysql::class, 'mysql'],
'mariadb' => [StandaloneMariadb::class, 'mariadb'],
@@ -250,6 +250,7 @@ function restoreScriptRun(string $engine, string $contents, bool $dumpAll = fals
$escapedStdin = escapeshellarg($dir.'/stdin');
file_put_contents($dir.'/bin/'.$client, <<<SH
#!/bin/sh
if [ "\$*" = '--help' ]; then printf '%s\\n' "\${STUB_HELP:-}"; exit 0; fi
header=''
call=\$(wc -l < {$escapedLog} | tr -d ' ')
if [ ! -t 0 ]; then
@@ -735,6 +736,47 @@ test('rejects unsupported MySQL and MariaDB backups before calling any client',
'dump of two databases without dump-all' => ['mysql-two-databases', false, 'This backup contains more than one database.'],
]);
test('restores MySQL and MariaDB dumps with client commands disabled when the client supports it', function (string $engine, string $help, string $flag, bool $dumpAll) {
$run = restoreScriptRun($engine, restoreScriptFixture('mysql-sql'), $dumpAll, env: ['STUB_HELP' => $help]);
expect($run['exit'])->toBe(0, $run['stderr'])
->and(end($run['calls'])['args'])->toStartWith($flag.' -u ')
->and(end($run['calls'])['stdin'])->toBe(restoreScriptFixture('mysql-sql'));
})->with(['mysql', 'mariadb'])->with([
'MariaDB client' => [' --sandbox Disallow commands that access the file system', '--sandbox'],
'MySQL client' => [' --commands Whether to enable or disable processing of local mysql commands.', '--commands=FALSE'],
])->with(['single database' => false, 'all databases' => true]);
test('refuses MySQL and MariaDB dumps with client commands before calling any client', function (string $engine, string $command, bool $dumpAll) {
$dump = "-- MySQL dump 10.13\n\nCREATE TABLE `items` (`id` int);\n{$command}\nINSERT INTO `items` VALUES (1);\n";
foreach ([$dump, gzencode($dump)] as $contents) {
$run = restoreScriptRun($engine, $contents, $dumpAll);
expect($run['exit'])->toBe(1)
->and($run['stderr'])->toContain('client command')->toContain('Nothing was changed.')
->and($run['calls'])->toBe([]);
}
})->with(['mysql', 'mariadb'])->with([
'system' => 'system id',
'indented SYSTEM' => ' SYSTEM id;',
'\\!' => '\\! id',
'\\! after SQL' => 'SELECT 1; \\! id',
'tee' => 'tee /etc/cron.d/x',
'\\T' => '\\T /etc/cron.d/x',
'pager' => 'pager sh',
'source' => 'source /etc/shadow',
'\\.' => '\\. /etc/shadow',
])->with(['single database' => false, 'all databases' => true]);
test('restores MySQL and MariaDB dumps whose data only looks like client commands', function (string $engine) {
$dump = "-- MySQL dump 10.13\n/*M!999999\\- enable the sandbox mode */\nCREATE TABLE `items` (`t` text, `system` int);\nINSERT INTO `items` VALUES ('a\\\\! b\\\\\\\\T c\\'s \\\\. \\r\\n',1),('system id',2);\n";
$run = restoreScriptRun($engine, $dump);
expect($run['exit'])->toBe(0, $run['stderr'])
->and(end($run['calls'])['stdin'])->toBe($dump);
})->with(['mysql', 'mariadb']);
test('restores mongodump archives', function (string $fixture, bool $replaceExisting, bool $gzip) {
$run = restoreScriptRun('mongodb', restoreScriptFixture($fixture), false, $replaceExisting);
@@ -162,7 +162,7 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toStartWith("backup='/tmp/test.dump'");
expect($result)->toContain('stream | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE');
expect($result)->toContain('stream | mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE');
expect($result)->not->toContain("< '/tmp/test.dump'");
});
@@ -173,7 +173,7 @@ test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toStartWith("backup='/tmp/test.dump'");
expect($result)->toContain('stream | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE');
expect($result)->toContain('stream | mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE');
expect($result)->not->toContain("< '/tmp/test.dump'");
});
@@ -0,0 +1,27 @@
<?php
use App\Notifications\Dto\PushoverMessage;
use Illuminate\Log\Events\MessageLogged;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
it('builds the payload without writing to the log', function () {
$logged = [];
Log::listen(function (MessageLogged $event) use (&$logged) {
$logged[] = $event->message;
});
$payload = (new PushoverMessage(
title: 'Deployment failed',
message: 'Your deployment failed.',
buttons: [['text' => 'Open', 'url' => 'https://coolify.example.com/deployments/1']],
level: 'error',
))->toPayload('app-token', 'user-key');
expect($payload)
->token->toBe('app-token')
->user->toBe('user-key')
->and($logged)->toBeEmpty();
});