Files
coolify/tests/Feature/Proxy/ProxyPortSecurityTest.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

75 lines
2.9 KiB
PHP

<?php
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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::assertDidntRun(fn ($process): bool => str_contains((string) $process->command, '$(id)'));
});
it('refreshes the status of a running proxy even when its stored configuration is invalid', function () {
Process::fake([
'*' => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
]);
$this->server->private_key_id = PrivateKey::factory()->create(['team_id' => $this->server->team_id])->id;
$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->fresh()))->toBeFalse()
->and($this->server->fresh()->proxy->status)->toBe('running');
});
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);
});