Files
coolify/tests/Unit/ProxyPortValidationTest.php
T
Andras Bacsai 7ef7fee59a fix(proxy): accept Docker Compose merge tags in proxy configurations
Proxy port validation parsed the configuration with plain Symfony YAML,
which rejects Docker Compose tags such as !reset and !override. Proxy
start, restart and save then failed for these valid configurations, and
automatic start after a reboot stopped silently. v4.3.23 did not block
on such configurations.

The parser now reads custom tags and validates the values inside them,
so malformed ports are still rejected. CheckProxy refreshes the status
of a running proxy before it reads the configuration, as in v4.3.23, so
an unreadable configuration no longer hides a running proxy.
2026-10-01 07:16:26 +02:00

130 lines
7.3 KiB
PHP

<?php
use App\Services\ProxyPortParser;
use Symfony\Component\Yaml\Yaml;
it('extracts normalized published proxy ports from valid compose syntax', function (string $configuration, array $expected) {
expect(ProxyPortParser::fromConfiguration($configuration))->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]],
'port ranges are accepted but not checked one by one' => ["services:\n traefik:\n ports: ['80:80', '10000-10100:10000-10100/udp', '127.0.0.1:5000-5010:5000-5010', '3000-3005', '8000-8010:80']\n", [80]],
'protocols in any letter case' => ["services:\n traefik:\n ports: ['53:53/UDP', '9000:9000/TCP', '5432:5432/sctp']\n", [53, 9000, 5432]],
'random host port' => ["services:\n traefik:\n ports: ['127.0.0.1::5000', '[::1]::6000']\n", []],
'IPv6 host with a range' => ["services:\n traefik:\n ports: ['[::1]:6000-6001:6000-6001']\n", []],
'long syntax with a published range' => ["services:\n caddy:\n ports:\n - target: 443\n published: '8443-8444'\n protocol: UDP\n", []],
]);
it('accepts Docker Compose variables and checks only ports with a resolvable default', function (mixed $ports, array $expected) {
$configuration = Yaml::dump(['services' => ['traefik' => ['ports' => $ports]]], 8, 2);
expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected);
})->with([
'host port default' => [['${HTTP_PORT:-80}:80'], [80]],
'host port default without colon' => [['${HTTP_PORT-80}:80'], [80]],
'host port without default' => [['${HTTP_PORT}:80'], []],
'unbraced host port' => [['$HTTP_PORT:80'], []],
'required host port' => [['${HTTP_PORT:?HTTP_PORT must be set}:80'], []],
'alternative host port' => [['${HTTP_PORT:+8080}:80'], []],
'host IP with a host port default' => [['127.0.0.1:${P:-8080}:8080'], [8080]],
'host IP default' => [['${BIND_IP:-0.0.0.0}:80:80'], [80]],
'host IP without default' => [['${BIND_IP}:443:443'], []],
'container port variable' => [['8080:${CONTAINER_PORT}'], []],
'default with protocol' => [['${HTTPS_PORT:-443}:443/udp'], [443]],
'protocol variable' => [['443:443/${PROTOCOL:-udp}'], [443]],
'invalid default is skipped like an unset variable' => [['${HTTP_PORT:-abc}:80'], []],
'default range' => [['${RANGE:-10000-10100}:10000-10100'], []],
'mixed with literal ports' => [['${HTTP_PORT:-80}:80', '${HTTPS_PORT}:443', '8080:8080'], [80, 8080]],
'long syntax published default' => [[['target' => 80, 'published' => '${HTTP_PORT:-8080}']], [8080]],
'long syntax published variable' => [[['target' => 80, 'published' => '${HTTP_PORT}']], []],
'long syntax host IP default' => [[['target' => 80, 'published' => 80, 'host_ip' => '${BIND_IP:-127.0.0.1}']], [80]],
'long syntax target variable' => [[['target' => '${TARGET}', 'published' => 9000]], []],
]);
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 followed by a semicolon' => '${HTTP_PORT:-80};id:80',
'environment variable with command substitution' => '${HTTP_PORT}$(id):80',
'environment variable with backticks' => '`id`${HTTP_PORT}:80',
'environment variable default with command substitution' => '${HTTP_PORT:-$(id)}:80',
'environment variable with a pipe' => '${HTTP_PORT}:80|id',
'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',
'empty protocol' => '80:80/',
'host injection' => '`id`:8080:80',
'reversed range' => '10-5:10-5',
'range above the limit' => '65535-65536:1-2',
'range with command substitution' => '80-$(id):80',
'open range' => '80-:80',
'empty host port without a host IP' => ':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']],
'reversed published range' => [['target' => 80, 'published' => '90-80']],
'host injection' => [['target' => 80, 'published' => 8080, 'host_ip' => '$(id)']],
'variable with command substitution' => [['target' => 80, 'published' => '${HTTP_PORT}$(id)']],
'variable with an invalid protocol' => [['target' => 80, 'published' => '${HTTP_PORT}', 'protocol' => 'http']],
]);
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],
]);
it('accepts Docker Compose merge tags in proxy configurations', function (string $configuration, array $expected) {
expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected);
})->with([
'override ports' => ["services:\n traefik:\n ports: !override\n - '80:80'\n - '443:443'\n", [80, 443]],
'reset ports' => ["services:\n traefik:\n ports: !reset []\n", []],
'reset labels next to ports' => ["services:\n caddy:\n labels: !reset []\n ports: ['8080:80']\n", [8080]],
'override a long syntax port' => ["services:\n traefik:\n ports: !override\n - target: 80\n published: !!str 8080\n", [8080]],
]);
it('still rejects malformed ports inside Docker Compose merge tags', function () {
expect(fn () => ProxyPortParser::fromConfiguration("services:\n traefik:\n ports: !override\n - '\$(id):80'\n"))
->toThrow(InvalidArgumentException::class);
});