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:
parent
cc4f666ba2
commit
c6c7ec1c31
6 changed files with 72 additions and 38 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue