From 78f52310e50ebd5fc59f83ac8186d78d4b59793d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:56:50 +0200 Subject: [PATCH] fix(dns): only create or replace records for the resource's own domains --- .../Concerns/InteractsWithDnsProviders.php | 17 +- tests/Feature/ManagedDnsHostnameScopeTest.php | 159 ++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ManagedDnsHostnameScopeTest.php diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index e136bc4d1b..42a4cdd928 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -56,7 +56,7 @@ trait InteractsWithDnsProviders } $zone = $this->findTeamZone($zoneId, $hostname); $content = $this->dnsRecordContent($content); - if ($zone === null || $content === null) { + if ($zone === null || $content === null || ! $this->isDnsHostnameOfResource($hostname)) { $this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.'); return; @@ -174,7 +174,7 @@ trait InteractsWithDnsProviders } $zone = $this->findTeamZone($zoneId, $hostname); $content = $this->dnsRecordContent($conflict['proposed'] ?? null); - if ($conflict === null || $zone === null || $content === null) { + if ($conflict === null || $zone === null || $content === null || ! $this->isDnsHostnameOfResource($hostname)) { $this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.'); return; @@ -371,6 +371,19 @@ trait InteractsWithDnsProviders return $this->dnsResource()->team()?->id ?? throw new RuntimeException('The resource has no team.'); } + /** + * Whether the hostname belongs to a domain row of this resource. The rows are locked and built on the server; + * form inputs such as the new or edited domain are not trusted. + */ + protected function isDnsHostnameOfResource(string $hostname): bool + { + return collect($this->domainRows)->contains(function (array $row) use ($hostname): bool { + $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST); + + return is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname); + }); + } + /** * Zone of the resource team. With a hostname, only a zone that manages that hostname. */ diff --git a/tests/Feature/ManagedDnsHostnameScopeTest.php b/tests/Feature/ManagedDnsHostnameScopeTest.php new file mode 100644 index 0000000000..c5b1e02ae8 --- /dev/null +++ b/tests/Feature/ManagedDnsHostnameScopeTest.php @@ -0,0 +1,159 @@ +withoutVite(); + config(['app.maintenance.driver' => 'file']); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( + ['id' => 0], + ['id' => 0, 'is_dns_validation_enabled' => false], + )); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + test()->actingAs($user); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id, 'ip' => '203.0.113.10']); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'], + )); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + 'build_pack' => 'nixpacks', + ]); + $application->settings()->update(['is_container_label_readonly_enabled' => true]); + + $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']); + $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); + + return ['application' => $application, 'zone' => $zone]; +} + +function fakeCloudflareWithExistingWwwRecord(): void +{ + Http::fake([ + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ + 'success' => true, + 'result' => [['id' => 'record-www', 'type' => 'A', 'name' => 'www.company.example.com', 'content' => '198.51.100.50']], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-www' => Http::response([ + 'success' => true, 'result' => ['id' => 'record-www'], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response([ + 'success' => true, 'result' => ['id' => 'record-new'], + ]), + ]); +} + +test('an admin cannot create or replace a dns record for a hostname that is not a domain of the application', function () { + ['application' => $application, 'zone' => $zone] = prepareHostnameScopeDnsApplication(); + fakeCloudflareWithExistingWwwRecord(); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('createManagedDnsRecord', 'WWW.Company.Example.com', $zone->id) + ->assertDispatched('error') + ->assertSet('dnsProviderConflicts', []) + ->call('replaceManagedDnsRecord', 'www.company.example.com', $zone->id) + ->assertDispatched('error'); + + Http::assertNothingSent(); + expect(ManagedDnsRecord::query()->exists())->toBeFalse(); +}); + +test('an admin cannot create a dns record for a hostname typed into the add domain form', function () { + ['application' => $application, 'zone' => $zone] = prepareHostnameScopeDnsApplication(); + fakeCloudflareWithExistingWwwRecord(); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->set('newDomain', 'https://www.company.example.com') + ->call('createManagedDnsRecord', 'www.company.example.com', $zone->id) + ->assertDispatched('error'); + + Http::assertNothingSent(); + expect(ManagedDnsRecord::query()->exists())->toBeFalse(); +}); + +test('an admin cannot create a dns record for a hostname that is not a domain of the service', function () { + ['application' => $application, 'zone' => $zone] = prepareHostnameScopeDnsApplication(); + $service = Service::factory()->create([ + 'server_id' => $application->destination->server->id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + 'environment_id' => $application->environment_id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'human_name' => 'Web', + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web.example.com', + ]); + fakeCloudflareWithExistingWwwRecord(); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->call('createManagedDnsRecord', 'www.company.example.com', $zone->id) + ->assertDispatched('error') + ->call('replaceManagedDnsRecord', 'www.company.example.com', $zone->id) + ->assertDispatched('error'); + + Http::assertNothingSent(); + expect(ManagedDnsRecord::query()->exists())->toBeFalse(); +}); + +test('an admin can still create a dns record for a domain of the application', function () { + ['application' => $application, 'zone' => $zone] = prepareHostnameScopeDnsApplication(); + Http::fake([ + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]), + 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]), + ]); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('createManagedDnsRecord', 'App.Example.com', $zone->id) + ->assertDispatched('success', 'DNS record created for app.example.com.'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->data()['name'] === 'app.example.com' + && $request->data()['content'] === '203.0.113.10'); + expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue(); +});