From 98b7de0ea77a4c81a817cb3aa4905fadd169b7d8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:43:13 +0100 Subject: [PATCH] fix(api): accept the same port mappings as the UI Closes #12388 --- .../Api/ApplicationsController.php | 19 ++-- bootstrap/helpers/api.php | 2 +- .../ApplicationPortMappingsApiTest.php | 93 +++++++++++++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/ApplicationPortMappingsApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index f17e29cb4c..7c6b35589c 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5037,20 +5037,25 @@ class ApplicationsController extends Controller ], 422); } - // Validate ports_mappings - if ($request->has('ports_mappings')) { - $ports = []; + // Validate that each host binding (IP, port, protocol) of ports_mappings is unique + if (is_string($request->ports_mappings)) { + $hostBindings = []; foreach (explode(',', $request->ports_mappings) as $portMapping) { - $port = explode(':', $portMapping); - if (in_array($port[0], $ports)) { + // The container port is after the last ":", an empty host port makes Docker pick a free port + $hostBinding = preg_replace('#/(tcp|udp|sctp)#', '', substr($portMapping, 0, (int) strrpos($portMapping, ':'))); + if ($hostBinding === '' || str_ends_with($hostBinding, ':')) { + continue; + } + $protocol = preg_match('#/(udp|sctp)#', $portMapping, $matches) === 1 ? $matches[1] : 'tcp'; + if (in_array("{$hostBinding}/{$protocol}", $hostBindings, true)) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ - 'ports_mappings' => 'The first number before : should be unique between mappings.', + 'ports_mappings' => 'Each host IP, port, and protocol can only be used once.', ], ], 422); } - $ports[] = $port[0]; + $hostBindings[] = "{$hostBinding}/{$protocol}"; } } // Validate custom_labels diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 226df2a33a..42e739c5bd 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -278,7 +278,7 @@ function sharedDataApplications(?Application $application = null): array 'build_command' => ValidationPatterns::shellSafeCommandRules(), 'start_command' => ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', - 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', + 'ports_mappings' => ValidationPatterns::portMappingRules(), 'custom_network_aliases' => 'string|nullable', 'base_directory' => ValidationPatterns::directoryPathRules(), 'publish_directory' => ValidationPatterns::directoryPathRules(), diff --git a/tests/Feature/ApplicationPortMappingsApiTest.php b/tests/Feature/ApplicationPortMappingsApiTest.php new file mode 100644 index 0000000000..addb19355c --- /dev/null +++ b/tests/Feature/ApplicationPortMappingsApiTest.php @@ -0,0 +1,93 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'port-mappings-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $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(), + ]); +}); + +function patchPortMappings(string $bearerToken, Application $application, ?string $portsMappings) +{ + return test()->withHeaders(['Authorization' => 'Bearer '.$bearerToken]) + ->patchJson("/api/v1/applications/{$application->uuid}", ['ports_mappings' => $portsMappings]); +} + +test('accepts every port mapping format that the UI accepts', function (string $portsMappings) { + patchPortMappings($this->bearerToken, $this->application, $portsMappings)->assertOk(); + + expect($this->application->refresh()->ports_mappings)->toBe($portsMappings); +})->with([ + 'plain' => '8080:80', + 'udp protocol' => '8080:80/udp', + 'sctp protocol' => '8080:80/sctp', + 'port range' => '8000-8010:8000-8010', + 'ipv4 binding' => '127.0.0.1:8080:80', + 'ipv6 binding with random host port' => '[::1]::80', + 'same port for tcp and udp' => '53:53/tcp,53:53/udp', + 'same port on different host ips' => '127.0.0.1:8080:80,10.0.0.1:8080:80', + 'random host ports on one ip' => '127.0.0.1::80,127.0.0.1::81', +]); + +test('clears port mappings with null', function () { + $this->application->update(['ports_mappings' => '8080:80']); + + patchPortMappings($this->bearerToken, $this->application, null)->assertOk(); + + expect($this->application->refresh()->ports_mappings)->toBeNull(); +}); + +test('rejects invalid port mappings', function (string $portsMappings) { + patchPortMappings($this->bearerToken, $this->application, $portsMappings) + ->assertUnprocessable() + ->assertJsonValidationErrors('ports_mappings'); +})->with([ + 'missing container port' => '8080', + 'unknown protocol' => '8080:80/icmp', + 'shell characters' => '8080:80;id', +]); + +test('rejects duplicate host bindings', function (string $portsMappings) { + patchPortMappings($this->bearerToken, $this->application, $portsMappings) + ->assertUnprocessable() + ->assertJsonPath('errors.ports_mappings', 'Each host IP, port, and protocol can only be used once.'); +})->with([ + 'same host port' => '8080:80,8080:81', + 'same host port with explicit tcp' => '8080:80,8080:81/tcp', + 'same ip and host port' => '127.0.0.1:8080:80,127.0.0.1:8080:81', +]);