diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 99537e606f..47bc002d68 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -3,11 +3,12 @@ namespace App\Actions\Proxy; use App\Enums\ProxyTypes; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; +use App\Services\ProxyPortParser; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; -use Symfony\Component\Yaml\Yaml; class CheckProxy { @@ -52,6 +53,19 @@ class CheckProxy return true; } else { + $portsToCheck = []; + + try { + if ($server->proxyType() !== ProxyTypes::NONE->value) { + $proxyCompose = GetProxyConfiguration::run($server); + $portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose); + } + } catch (\Throwable $e) { + Log::error('Error checking proxy: '.$e->getMessage()); + + return false; + } + $status = getContainerStatus($server, $proxyContainerName); if ($status === 'running') { $server->proxy->set('status', 'running'); @@ -62,37 +76,6 @@ class CheckProxy if ($server->settings->is_cloudflare_tunnel) { return false; } - $ip = $server->ip; - if ($server->id === 0) { - $ip = 'host.docker.internal'; - } - $portsToCheck = []; - - try { - if ($server->proxyType() !== ProxyTypes::NONE->value) { - $proxyCompose = GetProxyConfiguration::run($server); - if (isset($proxyCompose)) { - $yaml = Yaml::parse($proxyCompose); - $configPorts = []; - if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { - $ports = data_get($yaml, 'services.traefik.ports'); - } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { - $ports = data_get($yaml, 'services.caddy.ports'); - } - if (isset($ports)) { - foreach ($ports as $port) { - $configPorts[] = str($port)->before(':')->value(); - } - } - // Combine default ports with config ports - $portsToCheck = array_merge($portsToCheck, $configPorts); - } - } else { - $portsToCheck = []; - } - } catch (\Exception $e) { - Log::error('Error checking proxy: '.$e->getMessage()); - } if (count($portsToCheck) === 0) { return false; } @@ -163,14 +146,31 @@ class CheckProxy /** * Build the SSH command for checking a specific port */ - private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array + private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array { + $portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); + + return [ + 'ssh_command' => $sshCommand, + 'script' => $portCheckScript, + ]; + } + + private function buildPortCheckScript(int $port, string $proxyContainerName): string + { + + $dockerPortPattern = escapeshellarg('"'.$port.'/tcp"'); + $socketPort = escapeshellarg(':'.$port); + $portSuffixPattern = escapeshellarg(':'.$port.' '); + $portArgument = escapeshellarg((string) $port); + // First check if our own proxy is using this port (which is fine) $getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'"; $checkProxyPortScript = " CONTAINER_ID=\$($getProxyContainerId); if [ ! -z \"\$CONTAINER_ID\" ]; then - if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then + if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q $dockerPortPattern; then echo 'proxy_using_port'; exit 0; fi; @@ -183,12 +183,12 @@ class CheckProxy # Try ss command first if command -v ss >/dev/null 2>&1; then - ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null); + ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null); if [ -z \"\$ss_output\" ]; then echo 'port_free'; exit 0; fi; - count=\$(echo \"\$ss_output\" | grep -c ':$port '); + count=\$(echo \"\$ss_output\" | grep -c $portSuffixPattern); if [ \$count -eq 0 ]; then echo 'port_free'; exit 0; @@ -204,7 +204,7 @@ class CheckProxy # Try netstat as fallback if command -v netstat >/dev/null 2>&1; then - netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port '); + netstat_output=\$(netstat -tuln 2>/dev/null | grep $portSuffixPattern); if [ -z \"\$netstat_output\" ]; then echo 'port_free'; exit 0; @@ -223,25 +223,20 @@ class CheckProxy fi; # Final fallback using nc - if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then + if nc -z -w1 127.0.0.1 $portArgument >/dev/null 2>&1; then echo 'port_conflict|nc_detected'; else echo 'port_free'; fi; "; - $sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); - - return [ - 'ssh_command' => $sshCommand, - 'script' => $portCheckScript, - ]; + return $portCheckScript; } /** * Parse the result from port check command */ - private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool + private function parsePortCheckResult($processResult, int $port, string $proxyContainerName): bool { $exitCode = $processResult->exitCode(); $output = trim($processResult->output()); @@ -282,15 +277,19 @@ class CheckProxy * Smart port checker that handles dual-stack configurations * Returns true only if there's a real port conflict (not just dual-stack) */ - private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool + private function isPortConflict(Server $server, int $port, string $proxyContainerName): bool { + $dockerPortPattern = escapeshellarg('"'.$port.'/tcp"'); + $socketPort = escapeshellarg(':'.$port); + $portSuffixPattern = escapeshellarg(':'.$port.' '); + // First check if our own proxy is using this port (which is fine) try { $getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'"; $containerId = trim(instant_remote_process([$getProxyContainerId], $server)); if (! empty($containerId)) { - $checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'"; + $checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep $dockerPortPattern"; try { instant_remote_process([$checkProxyPort], $server); @@ -311,9 +310,9 @@ class CheckProxy 'available' => 'command -v ss >/dev/null 2>&1', 'check' => [ // Get listening process details - "ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"", + "ss_output=\$(ss -Htuln state listening sport = $socketPort 2>/dev/null) && echo \"\$ss_output\"", // Count IPv4 listeners - "echo \"\$ss_output\" | grep -c ':$port '", + "echo \"\$ss_output\" | grep -c $portSuffixPattern", ], ], // Set 2: Use netstat as alternative to ss @@ -321,9 +320,9 @@ class CheckProxy 'available' => 'command -v netstat >/dev/null 2>&1', 'check' => [ // Get listening process details - "netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '", + "netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep $portSuffixPattern", // Count listeners - "echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'", + "echo \"\$netstat_output\" | grep $portSuffixPattern | grep -c 'LISTEN'", ], ], // Set 3: Use lsof as last resort @@ -331,9 +330,9 @@ class CheckProxy 'available' => 'command -v lsof >/dev/null 2>&1', 'check' => [ // Get process using the port - "lsof -i :$port -P -n | grep 'LISTEN'", + "lsof -i $socketPort -P -n | grep 'LISTEN'", // Count listeners - "lsof -i :$port -P -n | grep 'LISTEN' | wc -l", + "lsof -i $socketPort -P -n | grep 'LISTEN' | wc -l", ], ], ]; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index d09aae802a..d910b7b0aa 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -5,6 +5,7 @@ namespace App\Actions\Proxy; use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use App\Services\ProxyPortParser; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -112,6 +113,7 @@ class GetProxyConfiguration } if (! empty(trim($result ?? ''))) { + ProxyPortParser::fromConfiguration($result); $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index bcfd5011d2..75775c8d6e 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -3,6 +3,8 @@ namespace App\Actions\Proxy; use App\Models\Server; +use App\Services\ProxyPortParser; +use Illuminate\Validation\ValidationException; use Lorisleiva\Actions\Concerns\AsAction; class SaveProxyConfiguration @@ -13,6 +15,14 @@ class SaveProxyConfiguration public function handle(Server $server, string $configuration): void { + try { + ProxyPortParser::fromConfiguration($configuration); + } catch (\InvalidArgumentException $exception) { + throw ValidationException::withMessages([ + 'configuration' => [$exception->getMessage()], + ]); + } + $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 20c9976564..8ee6c6f242 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -6,6 +6,7 @@ use App\Enums\ProxyTypes; use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyPortParser; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Models\Activity; @@ -19,6 +20,12 @@ class StartProxy if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) { return 'OK'; } + $configuration = GetProxyConfiguration::run($server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + ProxyPortParser::fromConfiguration($configuration); + $server->proxy->set('status', 'starting'); $server->save(); $server->refresh(); @@ -29,10 +36,6 @@ class StartProxy $commands = collect([]); $proxy_path = $server->proxyPath(); - $configuration = GetProxyConfiguration::run($server); - if (! $configuration) { - throw new \Exception('Configuration is not synced'); - } SaveProxyConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php index acfddd677a..b47f03d0b6 100644 --- a/app/Actions/Server/ConfigureTrafficAnalytics.php +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; use App\Jobs\RestartProxyJob; use App\Models\Server; +use App\Services\ProxyPortParser; use Lorisleiva\Actions\Concerns\AsAction; class ConfigureTrafficAnalytics @@ -14,13 +15,15 @@ class ConfigureTrafficAnalytics public function handle(Server $server, bool $enable): void { + $configuration = GetProxyConfiguration::run($server); + ProxyPortParser::fromConfiguration($configuration); + $sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled; $server->settings->is_traffic_analytics_enabled = $enable; $server->settings->save(); $server->refresh(); - $configuration = GetProxyConfiguration::run($server); $configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration); SaveProxyConfiguration::run($server, $configuration); RestartProxyJob::dispatch($server); diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index c5eb8c7fc5..6f0c535db3 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -8,6 +8,7 @@ use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use App\Services\ProxyPortParser; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -36,13 +37,19 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public function handle() { try { + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + ProxyPortParser::fromConfiguration($configuration); + // Set status to restarting $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); // Build combined stop + start commands for a single activity - $commands = $this->buildRestartCommands(); + $commands = $this->buildRestartCommands($configuration); // Create activity and dispatch immediately - returns Activity right away // The remote_process runs asynchronously, so UI gets activity ID instantly @@ -57,6 +64,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue $this->activity_id = $activity->id; ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); + } catch (\InvalidArgumentException $e) { + return handleError($e); } catch (\Throwable $e) { // Set error status $this->server->proxy->status = 'error'; @@ -76,18 +85,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue * Build combined stop + start commands for proxy restart. * This creates a single command sequence that shows all logs in one activity. */ - private function buildRestartCommands(): array + private function buildRestartCommands(string $configuration): array { $proxyType = $this->server->proxyType(); $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; $proxy_path = $this->server->proxyPath(); $stopTimeout = 30; - // Get proxy configuration - $configuration = GetProxyConfiguration::run($this->server); - if (! $configuration) { - throw new \Exception('Configuration is not synced'); - } SaveProxyConfiguration::run($this->server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); diff --git a/app/Services/ProxyPortParser.php b/app/Services/ProxyPortParser.php new file mode 100644 index 0000000000..f6e0dbfc39 --- /dev/null +++ b/app/Services/ProxyPortParser.php @@ -0,0 +1,152 @@ + + */ + public static function fromConfiguration(string $configuration): array + { + try { + $parsed = Yaml::parse($configuration); + } catch (ParseException $exception) { + throw new \InvalidArgumentException('The proxy configuration must contain valid YAML.', previous: $exception); + } + + if (! is_array($parsed)) { + return []; + } + + $ports = []; + + foreach (['traefik', 'caddy'] as $proxyService) { + $path = "services.{$proxyService}.ports"; + + if (! data_has($parsed, $path)) { + continue; + } + + $configuredPorts = data_get($parsed, $path); + if (! is_array($configuredPorts) || ! array_is_list($configuredPorts)) { + self::invalid(); + } + + foreach ($configuredPorts as $configuredPort) { + $ports[] = self::publishedPort($configuredPort); + } + } + + return array_values(array_unique($ports)); + } + + private static function publishedPort(mixed $configuredPort): int + { + if (is_array($configuredPort)) { + if (array_is_list($configuredPort) || ! array_key_exists('target', $configuredPort)) { + self::invalid(); + } + + self::validateProtocol($configuredPort['protocol'] ?? null); + if (array_key_exists('host_ip', $configuredPort)) { + self::validateHostIp($configuredPort['host_ip']); + } + $target = self::portNumber($configuredPort['target']); + + return array_key_exists('published', $configuredPort) + ? self::portNumber($configuredPort['published']) + : $target; + } + + if (! is_int($configuredPort) && ! is_string($configuredPort)) { + self::invalid(); + } + + if (is_int($configuredPort)) { + return self::portNumber($configuredPort); + } + + $portDefinition = $configuredPort; + $protocolSeparator = strrpos($portDefinition, '/'); + if ($protocolSeparator !== false) { + self::validateProtocol(substr($portDefinition, $protocolSeparator + 1)); + $portDefinition = substr($portDefinition, 0, $protocolSeparator); + } + + if (str_starts_with($portDefinition, '[')) { + if (! preg_match('/^\[([^]]+)]:(\d+):(\d+)$/D', $portDefinition, $matches)) { + self::invalid(); + } + + self::validateHostIp($matches[1]); + self::portNumber($matches[3]); + + return self::portNumber($matches[2]); + } + + $parts = explode(':', $portDefinition); + if (count($parts) < 1 || count($parts) > 3) { + self::invalid(); + } + + if (count($parts) === 3 && $parts[0] === '') { + self::invalid(); + } + + if (count($parts) === 3) { + self::validateHostIp($parts[0]); + } + + $portParts = count($parts) === 3 ? array_slice($parts, 1) : $parts; + foreach ($portParts as $part) { + self::portNumber($part); + } + + return self::portNumber($portParts[0]); + } + + private static function portNumber(mixed $port): int + { + if (is_int($port)) { + if ($port < 1 || $port > 65535) { + self::invalid(); + } + + return $port; + } + + if (! is_string($port) || preg_match('/^\d+$/D', $port) !== 1) { + self::invalid(); + } + + $normalized = (int) $port; + if ($normalized < 1 || $normalized > 65535) { + self::invalid(); + } + + return $normalized; + } + + private static function validateProtocol(mixed $protocol): void + { + if ($protocol !== null && (! is_string($protocol) || ! in_array($protocol, ['tcp', 'udp'], true))) { + self::invalid(); + } + } + + private static function validateHostIp(mixed $hostIp): void + { + if (! is_string($hostIp) || filter_var($hostIp, FILTER_VALIDATE_IP) === false) { + self::invalid(); + } + } + + private static function invalid(): never + { + throw new \InvalidArgumentException('Proxy ports must be integers from 1 through 65535.'); + } +} diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php index 03c88be2a9..85272dd9b5 100644 --- a/tests/Feature/Api/ServerProxyApiTest.php +++ b/tests/Feature/Api/ServerProxyApiTest.php @@ -209,6 +209,34 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configurati ->assertUnprocessable(); }); +test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection without mutation', function () { + $originalProxy = $this->server->proxy->toArray(); + $configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n"; + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode($configuration), + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.configuration.0', 'Proxy ports must be integers from 1 through 65535.'); + + expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy); +}); + +test('PUT /api/v1/servers/{uuid}/proxy/configuration forbids normal members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = serverProxyApiToken($member, $this->team, ['*']); + + SaveProxyConfiguration::shouldNotRun(); + + $this->withHeaders(serverProxyApiHeaders($memberToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode("services:\n traefik:\n ports: ['80:80']\n"), + ]) + ->assertForbidden(); +}); + test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () { Queue::fake(); diff --git a/tests/Feature/Proxy/ProxyPortSecurityTest.php b/tests/Feature/Proxy/ProxyPortSecurityTest.php new file mode 100644 index 0000000000..88ec25dc9d --- /dev/null +++ b/tests/Feature/Proxy/ProxyPortSecurityTest.php @@ -0,0 +1,61 @@ + 0]); + Process::fake(); + + $this->server = Server::factory()->create(['team_id' => Team::factory()]); + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->server->proxy->type = ProxyTypes::TRAEFIK->value; + $this->server->proxy->status = 'exited'; + $this->server->save(); + $this->server->refresh(); +}); + +it('rejects a malicious configuration before database or remote writes', function () { + $originalProxy = $this->server->proxy->toArray(); + $configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n"; + + expect(fn () => SaveProxyConfiguration::run($this->server, $configuration)) + ->toThrow(ValidationException::class); + + expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy); + Process::assertNothingRan(); +}); + +it('does not execute remote commands for a malformed legacy stored port', function () { + $this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.7\n ports:\n - '$(id):80'\n"; + $this->server->save(); + + expect(CheckProxy::run($this->server))->toBeFalse(); + Process::assertNothingRan(); +}); + +it('builds port checks only from normalized integer ports', function () { + $method = new ReflectionMethod(CheckProxy::class, 'buildPortCheckScript'); + $script = $method->invoke(new CheckProxy, 443, 'coolify-proxy'); + + expect($script) + ->toContain("grep -q '\"443/tcp\"'") + ->toContain("sport = ':443'") + ->toContain("nc -z -w1 127.0.0.1 '443'") + ->not->toContain('$(', '`', ';id') + ->and(fn () => $method->invoke(new CheckProxy, '80;id', 'coolify-proxy')) + ->toThrow(TypeError::class); +}); diff --git a/tests/Unit/ProxyPortValidationTest.php b/tests/Unit/ProxyPortValidationTest.php new file mode 100644 index 0000000000..f03c7dd640 --- /dev/null +++ b/tests/Unit/ProxyPortValidationTest.php @@ -0,0 +1,71 @@ +toBe($expected); +})->with([ + 'traefik short syntax' => ["services:\n traefik:\n ports: [80, '443:443', '127.0.0.1:8080:80', '0443:443/udp']\n", [80, 443, 8080]], + 'caddy long syntax' => ["services:\n caddy:\n ports:\n - target: 80\n published: '8080'\n protocol: tcp\n - target: 443\n", [8080, 443]], + 'both supported proxies' => ["services:\n traefik:\n ports: ['80:80']\n caddy:\n ports: ['443:443/udp']\n", [80, 443]], + 'range boundaries' => ["services:\n traefik:\n ports: ['1:1', '65535:65535']\n", [1, 65535]], +]); + +it('rejects malformed proxy port values', function (mixed $port) { + $configuration = Yaml::dump(['services' => ['traefik' => ['ports' => [$port]]]], 8, 2); + + expect(fn () => ProxyPortParser::fromConfiguration($configuration)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'zero' => 0, + 'too large' => 65536, + 'negative' => -1, + 'leading plus' => '+80', + 'decimal' => 80.5, + 'scientific notation' => '8e1', + 'comma separated' => '80,443', + 'environment variable' => '${HTTP_PORT:-80}:80', + 'leading whitespace' => ' 80', + 'trailing whitespace' => '80 ', + 'newline' => "80\nid", + 'semicolon' => '80;id', + 'command substitution' => '$(id):80', + 'backticks' => '`id`:80', + 'pipe' => '80|id', + 'logical operator' => '80&&id', + 'quote' => "80'", + 'option prefix' => '--help', + 'null' => null, + 'boolean' => true, + 'nested sequence' => [['80']], + 'nested map' => [['target' => ['80']]], + 'invalid protocol' => '80:80/http', + 'host injection' => '`id`:8080:80', +]); + +it('rejects malformed proxy ports in long compose syntax', function (array $port) { + $configuration = Yaml::dump(['services' => ['caddy' => ['ports' => [$port]]]], 8, 2); + + expect(fn () => ProxyPortParser::fromConfiguration($configuration)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'malicious published port' => [['target' => 80, 'published' => '$(id)']], + 'malicious target port' => [['target' => '80;id', 'published' => 8080]], + 'invalid published type' => [['target' => 80, 'published' => true]], + 'missing target' => [['published' => 8080]], + 'invalid protocol' => [['target' => 80, 'published' => 8080, 'protocol' => 'http']], + 'host injection' => [['target' => 80, 'published' => 8080, 'host_ip' => '$(id)']], +]); + +it('rejects invalid proxy port collection shapes', function (mixed $ports) { + $configuration = Yaml::dump(['services' => ['traefik' => ['ports' => $ports]]], 8, 2); + + expect(fn () => ProxyPortParser::fromConfiguration($configuration)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'scalar' => ['80:80'], + 'map' => [['published' => 80]], + 'null' => [null], + 'boolean' => [true], +]);