From 32541a32cec22a051f972c56ce8510c566b2e84b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:29:05 +0100 Subject: [PATCH] fix(api): validate resource names and descriptions like the UI The API and MCP saved names and descriptions that the UI rejects, so the resource could not be saved in the UI after that. Applications, services, databases, servers, and private keys now use the UI name and description rules on create and update, with the UI error messages. Closes #12466 Co-Authored-By: Claude Opus 5.5 --- .../Api/ApplicationsController.php | 8 +- .../Controllers/Api/DatabasesController.php | 16 +- .../Controllers/Api/SecurityController.php | 13 +- .../Controllers/Api/ServersController.php | 10 +- .../Controllers/Api/ServicesController.php | 19 ++- app/Support/ValidationPatterns.php | 6 +- ...sourceNameDescriptionValidationApiTest.php | 161 ++++++++++++++++++ 7 files changed, 201 insertions(+), 32 deletions(-) create mode 100644 tests/Feature/Api/ResourceNameDescriptionValidationApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ce5110bfbd..4aa7bfdc29 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1595,8 +1595,8 @@ class ApplicationsController extends Controller $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'project_uuid' => 'string|required', 'environment_name' => 'string|nullable', 'environment_uuid' => 'string|nullable', @@ -3638,10 +3638,10 @@ class ApplicationsController extends Controller $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ - 'name' => 'string|max:255', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), 'github_app_uuid' => 'string', 'dockerfile' => 'string', - 'description' => 'string|nullable', + 'description' => ValidationPatterns::descriptionRules(), 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain,redirect', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index ddc09fb1c8..96ee88209a 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -659,8 +659,8 @@ class DatabasesController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'image' => 'string', 'is_public' => 'boolean', 'public_port' => 'numeric|nullable|between:1024,65535', @@ -676,7 +676,7 @@ class DatabasesController extends Controller 'redact_all_env_values_in_logs' => 'boolean', 'include_coolify_default_variables' => 'boolean', 'is_log_drain_enabled' => 'boolean', - ]); + ], ValidationPatterns::combinedMessages()); if ($validator->fails()) { return response()->json([ @@ -2237,8 +2237,8 @@ class DatabasesController extends Controller } $this->normalizeNumericLimits($request); $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'image' => 'string', 'project_uuid' => 'string|required', 'environment_name' => 'string|nullable', @@ -2258,7 +2258,7 @@ class DatabasesController extends Controller 'instant_deploy' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', - ]); + ], ValidationPatterns::combinedMessages()); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -5233,9 +5233,9 @@ class DatabasesController extends Controller $validator = customApiValidator($request->all(), [ 'destination_uuid' => 'required|string', - 'name' => 'string|max:255|nullable', + 'name' => ValidationPatterns::nameRules(required: false), 'clone_volumes' => 'boolean', - ]); + ], ValidationPatterns::combinedMessages()); $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 9df897caa3..cd334f17a1 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\PrivateKey; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -187,10 +188,10 @@ class SecurityController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', - 'description' => 'string|max:255', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'private_key' => 'required|string', - ]); + ], ValidationPatterns::combinedMessages()); if ($validator->fails()) { $errors = $validator->errors(); @@ -325,10 +326,10 @@ class SecurityController extends Controller } $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', - 'description' => 'string|max:255', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'private_key' => 'string', - ]); + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index ce4b5f8f68..761050a34a 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -551,8 +551,8 @@ class ServersController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => ['string', 'max:255', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN], - 'description' => 'string|nullable', + 'name' => ['not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN, ...ValidationPatterns::nameRules(required: false, nullable: false)], + 'description' => ValidationPatterns::descriptionRules(), 'ip' => ['string', 'required', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', @@ -563,6 +563,7 @@ class ServersController extends Controller 'proxy_type' => 'string|nullable', ], [ ...ValidationPatterns::serverUsernameMessages(), + ...ValidationPatterns::combinedMessages(), 'name.not_regex' => 'The name must not contain control characters such as new lines.', ]); @@ -756,8 +757,8 @@ class ServersController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => ['string', 'max:255', 'nullable', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN], - 'description' => 'string|nullable', + 'name' => ['not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN, ...ValidationPatterns::nameRules(required: false, nullable: false)], + 'description' => ValidationPatterns::descriptionRules(), 'ip' => ['string', 'nullable', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', @@ -783,6 +784,7 @@ class ServersController extends Controller 'backup_compression_cpu_percentage' => 'integer|in:25,50,75,100', ], [ ...ValidationPatterns::serverUsernameMessages(), + ...ValidationPatterns::combinedMessages(), 'name.not_regex' => 'The name must not contain control characters such as new lines.', ]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 8e11c471f7..cd52dc0aff 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -416,8 +416,8 @@ class ServicesController extends Controller 'environment_uuid' => 'string|nullable', 'server_uuid' => 'string|required', 'destination_uuid' => 'string|nullable', - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', 'urls' => 'array|nullable', @@ -431,6 +431,7 @@ class ServicesController extends Controller 'tags.*' => 'string|min:2', ]; $validationMessages = [ + ...ValidationPatterns::combinedMessages(), 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -640,8 +641,8 @@ class ServicesController extends Controller 'environment_uuid' => 'string|nullable', 'server_uuid' => 'string|required', 'destination_uuid' => 'string', - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', 'docker_compose_raw' => 'string|required', @@ -656,6 +657,7 @@ class ServicesController extends Controller 'tags.*' => 'string|min:2', ]; $validationMessages = [ + ...ValidationPatterns::combinedMessages(), 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -1244,8 +1246,8 @@ class ServicesController extends Controller $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'include_coolify_default_variables']; $validationRules = [ - 'name' => 'string|max:255', - 'description' => 'string|nullable', + 'name' => ValidationPatterns::nameRules(required: false, nullable: false), + 'description' => ValidationPatterns::descriptionRules(), 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', 'docker_compose_raw' => 'string|nullable', @@ -1259,6 +1261,7 @@ class ServicesController extends Controller 'include_coolify_default_variables' => 'boolean', ]; $validationMessages = [ + ...ValidationPatterns::combinedMessages(), 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -3298,9 +3301,9 @@ class ServicesController extends Controller $validator = customApiValidator($request->all(), [ 'destination_uuid' => 'required|string', - 'name' => 'string|max:255|nullable', + 'name' => ValidationPatterns::nameRules(required: false), 'clone_volumes' => 'boolean', - ]); + ], ValidationPatterns::combinedMessages()); $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 1aa46ddabd..1bcd51d07f 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -488,14 +488,16 @@ class ValidationPatterns /** * Get validation rules for name fields + * + * Set $nullable to false for optional fields that may be left out but not set to null. */ - public static function nameRules(bool $required = true, int $minLength = 3, int $maxLength = 255): array + public static function nameRules(bool $required = true, int $minLength = 3, int $maxLength = 255, bool $nullable = true): array { $rules = []; if ($required) { $rules[] = 'required'; - } else { + } elseif ($nullable) { $rules[] = 'nullable'; } diff --git a/tests/Feature/Api/ResourceNameDescriptionValidationApiTest.php b/tests/Feature/Api/ResourceNameDescriptionValidationApiTest.php new file mode 100644 index 0000000000..ed6f1a100a --- /dev/null +++ b/tests/Feature/Api/ResourceNameDescriptionValidationApiTest.php @@ -0,0 +1,161 @@ + : < ]}'; +const UI_ACCEPTED_DESCRIPTION = 'Main API (prod) - v2.0, owner "ops" & admin@example.com! Ünïcode 日本'; + +beforeEach(function () { + config([ + 'cache.default' => 'array', + 'session.driver' => 'array', + 'app.maintenance.driver' => 'file', + 'constants.ssh.mux_enabled' => false, + ]); + Queue::fake(); + Bus::fake(); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $token = $this->user->createToken('write-token', ['write']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->headers = ['Authorization' => 'Bearer '.$token->plainTextToken]; + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'description' => 'original']); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'description' => 'original', + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +/** + * @return array{0: string, 1: Application|Service|StandalonePostgresql|Server|PrivateKey} + */ +function describedResource(object $test, string $type): array +{ + $destination = [ + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + 'description' => 'original', + ]; + + return match ($type) { + 'application' => ['applications', Application::factory()->create($destination)], + 'service' => ['services', Service::factory()->create([ + ...$destination, + 'server_id' => $test->server->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ])], + 'database' => ['databases', StandalonePostgresql::create([ + ...$destination, + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + ])], + 'server' => ['servers', $test->server], + 'private key' => ['security/keys', $test->privateKey], + }; +} + +$resourceTypes = ['application', 'service', 'database', 'server', 'private key']; + +test('updating a resource rejects a description that the UI rejects', function (string $type) { + [$path, $resource] = describedResource($this, $type); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/{$path}/{$resource->uuid}", ['description' => UI_REJECTED_DESCRIPTION]) + ->assertUnprocessable() + ->assertJsonValidationErrors('description'); + + expect($resource->fresh()->description)->toBe('original'); +})->with($resourceTypes); + +test('updating a resource accepts a description that the UI accepts', function (string $type) { + [$path, $resource] = describedResource($this, $type); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/{$path}/{$resource->uuid}", ['description' => UI_ACCEPTED_DESCRIPTION]) + ->assertSuccessful(); + + expect($resource->fresh()->description)->toBe(UI_ACCEPTED_DESCRIPTION); +})->with($resourceTypes); + +test('updating a resource rejects a name that the UI rejects', function (string $type, mixed $name) { + [$path, $resource] = describedResource($this, $type); + $originalName = $resource->name; + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/{$path}/{$resource->uuid}", ['name' => $name]) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); + + expect($resource->fresh()->name)->toBe($originalName); +})->with($resourceTypes)->with([ + 'disallowed characters' => '{[ > < ]}', + 'too short' => 'ab', + 'null' => null, +]); + +test('updating a resource accepts a name that the UI accepts', function (string $type) { + [$path, $resource] = describedResource($this, $type); + $name = 'owner/repo:main (prod) #2 & co, ünï @ v1.0+'; + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/{$path}/{$resource->uuid}", ['name' => $name]) + ->assertSuccessful(); + + expect($resource->fresh()->name)->toBe($name); +})->with($resourceTypes); + +test('creating a private key rejects a description that the UI rejects', function () { + $this->withHeaders($this->headers) + ->postJson('/api/v1/security/keys', [ + 'private_key' => generateSSHKey('ed25519')['private'], + 'description' => UI_REJECTED_DESCRIPTION, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('description'); + + expect(PrivateKey::where('description', UI_REJECTED_DESCRIPTION)->exists())->toBeFalse(); +}); + +test('creating a postgresql database rejects a description that the UI rejects', function () { + $this->withHeaders($this->headers) + ->postJson('/api/v1/databases/postgresql', [ + 'server_uuid' => $this->server->uuid, + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'description' => UI_REJECTED_DESCRIPTION, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('description'); + + expect(StandalonePostgresql::where('description', UI_REJECTED_DESCRIPTION)->exists())->toBeFalse(); +});