feat(validation): add custom validation rules for Git repository URLs and branches

- Introduced `ValidGitRepositoryUrl` and `ValidGitBranch` validation rules to ensure safe and valid input for Git repository URLs and branch names.
- Updated relevant Livewire components and API controllers to utilize the new validation rules, enhancing security against command injection and invalid inputs.
- Refactored existing validation logic to improve consistency and maintainability across the application.
This commit is contained in:
Andras Bacsai
2025-08-22 14:38:21 +02:00
parent 841e33bac0
commit 8408205955
9 changed files with 393 additions and 28 deletions

View File

@@ -15,6 +15,8 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -831,8 +833,8 @@ class ApplicationsController extends Controller
$destination = $destinations->first(); $destination = $destinations->first();
if ($type === 'public') { if ($type === 'public') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string', 'docker_compose_location' => 'string',
@@ -883,7 +885,7 @@ class ApplicationsController extends Controller
$application->source_type = GithubApp::class; $application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id; $application->source_id = GithubApp::find(0)->id;
} }
$application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2); $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->destination_id = $destination->id; $application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass(); $application->destination_type = $destination->getMorphClass();
@@ -935,7 +937,7 @@ class ApplicationsController extends Controller
} elseif ($type === 'private-gh-app') { } elseif ($type === 'private-gh-app') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => 'string|required',
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required', 'github_app_uuid' => 'string|required',
@@ -1043,7 +1045,7 @@ class ApplicationsController extends Controller
$application->docker_compose_domains = $dockerComposeDomainsJson; $application->docker_compose_domains = $dockerComposeDomainsJson;
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->git_repository = $gitRepository; $application->git_repository = str($gitRepository)->trim()->toString();
$application->destination_id = $destination->id; $application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass(); $application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id; $application->environment_id = $environment->id;
@@ -1090,8 +1092,8 @@ class ApplicationsController extends Controller
} elseif ($type === 'private-deploy-key') { } elseif ($type === 'private-deploy-key') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required', 'private_key_uuid' => 'string|required',

View File

@@ -7,6 +7,7 @@ use App\Models\GithubApp;
use App\Models\Project; use App\Models\Project;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Livewire\Component; use Livewire\Component;
@@ -155,6 +156,21 @@ class GithubPrivateRepository extends Component
public function submit() public function submit()
{ {
try { try {
// Validate git repository parts and branch
$validator = validator([
'selected_repository_owner' => $this->selected_repository_owner,
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
$destination_uuid = $this->query['destination']; $destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
if (! $destination) { if (! $destination) {
@@ -171,8 +187,8 @@ class GithubPrivateRepository extends Component
$application = Application::create([ $application = Application::create([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
'repository_project_id' => $this->selected_repository_id, 'repository_project_id' => $this->selected_repository_id,
'git_repository' => "{$this->selected_repository_owner}/{$this->selected_repository_repo}", 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
'git_branch' => $this->selected_branch_name, 'git_branch' => str($this->selected_branch_name)->trim()->toString(),
'build_pack' => $this->build_pack, 'build_pack' => $this->build_pack,
'ports_exposes' => $this->port, 'ports_exposes' => $this->port,
'publish_directory' => $this->publish_directory, 'publish_directory' => $this->publish_directory,

View File

@@ -9,6 +9,8 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@@ -53,17 +55,29 @@ class GithubPrivateRepositoryDeployKey extends Component
private ?string $git_host = null; private ?string $git_host = null;
private string $git_repository; private ?string $git_repository = null;
protected $rules = [ protected $rules = [
'repository_url' => 'required', 'repository_url' => ['required', 'string'],
'branch' => 'required|string', 'branch' => ['required', 'string'],
'port' => 'required|numeric', 'port' => 'required|numeric',
'is_static' => 'required|boolean', 'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string', 'publish_directory' => 'nullable|string',
'build_pack' => 'required|string', 'build_pack' => 'required|string',
]; ];
protected function rules()
{
return [
'repository_url' => ['required', 'string', new ValidGitRepositoryUrl],
'branch' => ['required', 'string', new ValidGitBranch],
'port' => 'required|numeric',
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
];
}
protected $validationAttributes = [ protected $validationAttributes = [
'repository_url' => 'Repository', 'repository_url' => 'Repository',
'branch' => 'Branch', 'branch' => 'Branch',
@@ -135,6 +149,9 @@ class GithubPrivateRepositoryDeployKey extends Component
$this->get_git_source(); $this->get_git_source();
// Note: git_repository has already been validated and transformed in get_git_source()
// It may now be in SSH format (git@host:repo.git) which is valid for deploy keys
$project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
if ($this->git_source === 'other') { if ($this->git_source === 'other') {
@@ -194,6 +211,15 @@ class GithubPrivateRepositoryDeployKey extends Component
private function get_git_source() private function get_git_source()
{ {
// Validate repository URL before parsing
$validator = validator(['repository_url' => $this->repository_url], [
'repository_url' => ['required', 'string', new ValidGitRepositoryUrl],
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
}
$this->repository_url_parsed = Url::fromString($this->repository_url); $this->repository_url_parsed = Url::fromString($this->repository_url);
$this->git_host = $this->repository_url_parsed->getHost(); $this->git_host = $this->repository_url_parsed->getHost();
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
@@ -206,8 +232,10 @@ class GithubPrivateRepositoryDeployKey extends Component
if (str($this->repository_url)->startsWith('http')) { if (str($this->repository_url)->startsWith('http')) {
$this->git_host = $this->repository_url_parsed->getHost(); $this->git_host = $this->repository_url_parsed->getHost();
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
// Convert to SSH format for deploy key usage
$this->git_repository = Str::finish("git@$this->git_host:$this->git_repository", '.git'); $this->git_repository = Str::finish("git@$this->git_host:$this->git_repository", '.git');
} else { } else {
// If it's already in SSH format, just use it as-is
$this->git_repository = $this->repository_url; $this->git_repository = $this->repository_url;
} }
$this->git_source = 'other'; $this->git_source = 'other';

View File

@@ -9,6 +9,8 @@ use App\Models\Project;
use App\Models\Service; use App\Models\Service;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use Carbon\Carbon; use Carbon\Carbon;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@@ -62,7 +64,7 @@ class PublicGitRepository extends Component
public bool $new_compose_services = false; public bool $new_compose_services = false;
protected $rules = [ protected $rules = [
'repository_url' => 'required|url', 'repository_url' => ['required', 'string'],
'port' => 'required|numeric', 'port' => 'required|numeric',
'isStatic' => 'required|boolean', 'isStatic' => 'required|boolean',
'publish_directory' => 'nullable|string', 'publish_directory' => 'nullable|string',
@@ -71,6 +73,20 @@ class PublicGitRepository extends Component
'docker_compose_location' => 'nullable|string', 'docker_compose_location' => 'nullable|string',
]; ];
protected function rules()
{
return [
'repository_url' => ['required', 'string', new ValidGitRepositoryUrl],
'port' => 'required|numeric',
'isStatic' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => 'nullable|string',
'git_branch' => ['required', 'string', new ValidGitBranch],
];
}
protected $validationAttributes = [ protected $validationAttributes = [
'repository_url' => 'repository', 'repository_url' => 'repository',
'port' => 'port', 'port' => 'port',
@@ -141,6 +157,15 @@ class PublicGitRepository extends Component
public function loadBranch() public function loadBranch()
{ {
try { try {
// Validate repository URL
$validator = validator(['repository_url' => $this->repository_url], [
'repository_url' => ['required', 'string', new ValidGitRepositoryUrl],
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
}
if (str($this->repository_url)->startsWith('git@')) { if (str($this->repository_url)->startsWith('git@')) {
$github_instance = str($this->repository_url)->after('git@')->before(':'); $github_instance = str($this->repository_url)->after('git@')->before(':');
$repository = str($this->repository_url)->after(':')->before('.git'); $repository = str($this->repository_url)->after(':')->before('.git');
@@ -191,6 +216,15 @@ class PublicGitRepository extends Component
$this->git_branch = 'main'; $this->git_branch = 'main';
$this->base_directory = '/'; $this->base_directory = '/';
// Validate repository URL before parsing
$validator = validator(['repository_url' => $this->repository_url], [
'repository_url' => ['required', 'string', new ValidGitRepositoryUrl],
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
}
$this->repository_url_parsed = Url::fromString($this->repository_url); $this->repository_url_parsed = Url::fromString($this->repository_url);
$this->git_host = $this->repository_url_parsed->getHost(); $this->git_host = $this->repository_url_parsed->getHost();
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
@@ -234,6 +268,27 @@ class PublicGitRepository extends Component
{ {
try { try {
$this->validate(); $this->validate();
// Additional validation for git repository and branch
if ($this->git_source === 'other') {
// For 'other' sources, git_repository contains the full URL
$validator = validator(['git_repository' => $this->git_repository], [
'git_repository' => ['required', 'string', new ValidGitRepositoryUrl],
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('git_repository'));
}
}
$branchValidator = validator(['git_branch' => $this->git_branch], [
'git_branch' => ['required', 'string', new ValidGitBranch],
]);
if ($branchValidator->fails()) {
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
$destination_uuid = $this->query['destination']; $destination_uuid = $this->query['destination'];
$project_uuid = $this->parameters['project_uuid']; $project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid']; $environment_uuid = $this->parameters['environment_uuid'];

View File

@@ -1128,15 +1128,20 @@ class Application extends BaseModel
$branch = $this->git_branch; $branch = $this->git_branch;
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
$baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid);
// Escape shell arguments for safety to prevent command injection
$escapedBranch = escapeshellarg($branch);
$escapedBaseDir = escapeshellarg($baseDir);
$commands = collect([]); $commands = collect([]);
// Check if shallow clone is enabled // Check if shallow clone is enabled
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
$depthFlag = $isShallowCloneEnabled ? ' --depth=1' : ''; $depthFlag = $isShallowCloneEnabled ? ' --depth=1' : '';
$git_clone_command = "git clone{$depthFlag} -b \"{$this->git_branch}\""; $git_clone_command = "git clone{$depthFlag} -b {$escapedBranch}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = "git clone{$depthFlag} --no-checkout -b \"{$this->git_branch}\""; $git_clone_command = "git clone{$depthFlag} --no-checkout -b {$escapedBranch}";
} }
if ($pull_request_id !== 0) { if ($pull_request_id !== 0) {
$pr_branch_name = "pr-{$pull_request_id}-coolify"; $pr_branch_name = "pr-{$pull_request_id}-coolify";
@@ -1150,7 +1155,8 @@ class Application extends BaseModel
if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { if ($this->source->getMorphClass() === \App\Models\GithubApp::class) {
if ($this->source->is_public) { if ($this->source->is_public) {
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$git_clone_command = "{$git_clone_command} {$this->source->html_url}/{$customRepository} {$baseDir}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
if (! $only_checkout) { if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
} }
@@ -1162,11 +1168,15 @@ class Application extends BaseModel
} else { } else {
$github_access_token = generateGithubInstallationToken($this->source); $github_access_token = generateGithubInstallationToken($this->source);
if ($exec_in_docker) { if ($exec_in_docker) {
$git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git {$baseDir}"; $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl);
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$fullRepoUrl = $repoUrl;
} else { } else {
$git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}"; $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl);
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
$fullRepoUrl = $repoUrl;
} }
if (! $only_checkout) { if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false);
@@ -1181,10 +1191,11 @@ class Application extends BaseModel
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name);
$escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command")); $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
} else { } else {
$commands->push("cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command"); $commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command");
} }
} }
@@ -1202,7 +1213,8 @@ class Application extends BaseModel
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
} }
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$customRepository} {$baseDir}"; $escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = $git_clone_command_base; $git_clone_command = $git_clone_command_base;
} else { } else {

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class ValidGitBranch implements ValidationRule
{
/**
* Run the validation rule.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$branch = trim($value);
// Check for dangerous shell metacharacters
$dangerousChars = [
';', '|', '&', '$', '`', '(', ')', '{', '}',
'<', '>', '\n', '\r', '\0', '"', "'", '\\',
'!', '*', '?', '[', ']', '~', '^', ':', ' ',
'#',
];
foreach ($dangerousChars as $char) {
if (str_contains($branch, $char)) {
Log::warning('Git branch validation failed - dangerous character', [
'branch' => $branch,
'character' => $char,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute contains invalid characters.');
return;
}
}
// Git branch name rules:
// - Cannot contain: .., //, @{
// - Cannot start or end with: / or .
// - Cannot be empty after trimming
if (str_contains($branch, '..') ||
str_contains($branch, '//') ||
str_contains($branch, '@{')) {
$fail('The :attribute contains invalid patterns.');
return;
}
if (str_starts_with($branch, '/') ||
str_ends_with($branch, '/') ||
str_starts_with($branch, '.') ||
str_ends_with($branch, '.')) {
$fail('The :attribute cannot start or end with / or .');
return;
}
// Allow only safe characters for branch names
// Letters, numbers, hyphens, underscores, forward slashes, and dots
if (! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $branch)) {
$fail('The :attribute contains invalid characters. Only letters, numbers, hyphens, underscores, forward slashes, and dots are allowed.');
return;
}
// Additional Git-specific validations
// Branch name cannot be 'HEAD'
if ($branch === 'HEAD') {
$fail('The :attribute cannot be HEAD.');
return;
}
// Check for consecutive dots (not allowed in Git)
if (str_contains($branch, '..')) {
$fail('The :attribute cannot contain consecutive dots.');
return;
}
// Check for .lock suffix (reserved by Git)
if (str_ends_with($branch, '.lock')) {
$fail('The :attribute cannot end with .lock.');
return;
}
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class ValidGitRepositoryUrl implements ValidationRule
{
protected bool $allowSSH;
protected bool $allowIP;
public function __construct(bool $allowSSH = true, bool $allowIP = false)
{
$this->allowSSH = $allowSSH;
$this->allowIP = $allowIP;
}
/**
* Run the validation rule.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
// Check for dangerous shell metacharacters that could be used for command injection
$dangerousChars = [
';', '|', '&', '$', '`', '(', ')', '{', '}',
'[', ']', '<', '>', '\n', '\r', '\0', '"', "'",
'\\', '!', '?', '*', '~', '^', '%', '=', '+',
'#', // Comment character that could hide commands
];
foreach ($dangerousChars as $char) {
if (str_contains($value, $char)) {
Log::warning('Git repository URL validation failed - dangerous character', [
'url' => $value,
'character' => $char,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute contains invalid characters.');
return;
}
}
// Check for command substitution patterns
$dangerousPatterns = [
'/\$\(.*\)/', // Command substitution $(...)
'/\${.*}/', // Variable expansion ${...}
'/;;/', // Double semicolon
'/&&/', // Command chaining
'/\|\|/', // Command chaining
'/>>/', // Redirect append
'/<</', // Here document
'/\\\n/', // Line continuation
'/\.\.[\/\\\\]/', // Directory traversal
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $value)) {
Log::warning('Git repository URL validation failed - dangerous pattern', [
'url' => $value,
'pattern' => $pattern,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute contains invalid patterns.');
return;
}
}
// Validate based on URL type
if (str_starts_with($value, 'git@')) {
if (! $this->allowSSH) {
$fail('SSH URLs are not allowed.');
return;
}
// Validate SSH URL format (git@host:user/repo.git)
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) {
$fail('The :attribute is not a valid SSH repository URL.');
return;
}
} elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
// Validate HTTP(S) URL
if (! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The :attribute is not a valid URL.');
return;
}
$parsed = parse_url($value);
// Check for IP addresses if not allowed
if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) {
Log::warning('Git repository URL contains IP address', [
'url' => $value,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute cannot use IP addresses.');
return;
}
// Check for localhost/internal addresses
$host = strtolower($parsed['host'] ?? '');
$internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) {
Log::warning('Git repository URL points to internal host', [
'url' => $value,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute cannot point to internal hosts.');
return;
}
// Ensure no query parameters or fragments
if (! empty($parsed['query']) || ! empty($parsed['fragment'])) {
$fail('The :attribute should not contain query parameters or fragments.');
return;
}
// Validate path contains only safe characters
$path = $parsed['path'] ?? '';
if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $path)) {
$fail('The :attribute path contains invalid characters.');
return;
}
} elseif (str_starts_with($value, 'git://')) {
// Validate git:// protocol URL
if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+\/[a-zA-Z0-9\-_\/\.]+$/', $value)) {
$fail('The :attribute is not a valid git:// URL.');
return;
}
} else {
$fail('The :attribute must start with https://, http://, git://, or git@.');
return;
}
}
}

View File

@@ -1,7 +1,7 @@
<div> <div>
<h1>Create a new Application</h1> <h1>Create a new Application</h1>
<div class="pb-4">Deploy any public or private Git repositories through a Deploy Key.</div> <div class="pb-4">Deploy any public or private Git repositories through a Deploy Key.</div>
<div class="flex flex-col pt-4"> <div class="flex flex-col ">
@if ($current_step === 'private_keys') @if ($current_step === 'private_keys')
<h2 class="pb-4">Select a private key</h2> <h2 class="pb-4">Select a private key</h2>
<div class="flex flex-col justify-center gap-2 text-left "> <div class="flex flex-col justify-center gap-2 text-left ">
@@ -46,9 +46,8 @@
</div> </div>
@endif @endif
@if ($current_step === 'repository') @if ($current_step === 'repository')
<h2 class="pb-4">Select a repository</h2> <form class="flex flex-col gap-2" wire:submit='submit'>
<form class="flex flex-col gap-2 pt-2" wire:submit='submit'> <x-forms.input id="repository_url" required label="Repository URL (https:// or git@)" />
<x-forms.input id="repository_url" required label="Repository Url (https:// or git@)" />
<div class="flex gap-2"> <div class="flex gap-2">
<x-forms.input id="branch" required label="Branch" /> <x-forms.input id="branch" required label="Branch" />
<x-forms.select wire:model.live="build_pack" label="Build Pack" required> <x-forms.select wire:model.live="build_pack" label="Build Pack" required>

View File

@@ -1,6 +1,6 @@
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })"> <div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<h1>Create a new Application</h1> <h1>Create a new Application</h1>
<div class="pb-4">Deploy any public Git repositories.</div> <div class="pb-8">Deploy any public Git repositories.</div>
<!-- Repository URL Form --> <!-- Repository URL Form -->
<form class="flex flex-col gap-2" wire:submit='loadBranch'> <form class="flex flex-col gap-2" wire:submit='loadBranch'>