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(); +});