fix(security): validate application domains safely

Use shared domain validation and normalization for application, service, and
preview domains so unsafe host input is rejected consistently.

Cover command-substitution payloads in application domain tests.
This commit is contained in:
Andras Bacsai 2026-07-02 17:47:04 +02:00
parent cc4f666ba2
commit c6c7ec1c31
6 changed files with 72 additions and 38 deletions

View file

@ -12,7 +12,6 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportEvents\Event; use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
class General extends Component class General extends Component
{ {
@ -142,7 +141,8 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable', 'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@ -771,16 +771,7 @@ public function submit($showToaster = true)
$oldBaseDirectory = $this->application->base_directory; $oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion // Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));
@ -863,6 +854,9 @@ public function submit($showToaster = true)
} }
} }
if ($this->buildPack === 'dockercompose') { if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) { if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) { foreach ($this->parsedServiceDomains as $service) {

View file

@ -6,6 +6,7 @@
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
@ -117,12 +118,14 @@ public function save_preview($preview_id)
}); });
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey]; $fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) { if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim(); $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$this->previewFqdns[$previewKey] = $fqdn; $this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) { if (! validateDNSEntry($fqdn, $this->application->destination->server)) {

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@ -33,6 +34,11 @@ public function save()
{ {
try { try {
$this->authorize('update', $this->preview->application); $this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; $docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@ -73,9 +79,13 @@ public function generate()
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else { } else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application // Use the existing domain from the main application
// Handle multiple domains separated by commas // Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string); $domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = []; $preview_fqdns = [];
$template = $this->preview->application->preview_url_template; $template = $this->preview->application->preview_url_template;
$random = new_public_id(); $random = new_public_id();

View file

@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component class EditDomain extends Component
{ {
@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null; public $requiredPort = null;
#[Validate(['nullable'])] #[Validate]
public ?string $fqdn = null; public ?string $fqdn = null;
protected $rules = [ protected function rules(): array
'fqdn' => 'nullable', {
]; return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount() public function mount()
{ {
@ -82,15 +85,9 @@ public function submit()
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -8,11 +8,11 @@
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component class Index extends Component
{ {
@ -480,15 +480,11 @@ public function submitApplication()
{ {
try { try {
$this->authorize('update', $this->serviceApplication); $this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate([
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); 'fqdn' => ValidationPatterns::applicationDomainRules(),
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { ]);
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -2,6 +2,40 @@
use App\Livewire\Project\Application\General; use App\Livewire\Project\Application\General;
it('uses safe domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'fqdn' => 'http://$(whoami).example.com',
], [
'fqdn' => $rules['fqdn'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('fqdn'))->toBeTrue();
});
it('uses safe docker compose service domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'parsedServiceDomains' => [
'app' => [
'domain' => 'http://$(whoami).example.com',
],
],
], [
'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
});
it('uses safe docker registry image validation rules in the application general form', function () { it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General; $component = new General;
$method = new ReflectionMethod($component, 'rules'); $method = new ReflectionMethod($component, 'rules');