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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 11:29:05 +01:00
co-authored by Claude Opus 5.5
parent 3bc82eb71d
commit 32541a32ce
7 changed files with 201 additions and 32 deletions
@@ -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',
@@ -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)) {
@@ -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)) {
@@ -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.',
]);
@@ -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)) {
+4 -2
View File
@@ -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';
}
@@ -0,0 +1,161 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
const UI_REJECTED_DESCRIPTION = '{[ > : < ]}';
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();
});