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:
@@ -15,6 +15,8 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -831,8 +833,8 @@ class ApplicationsController extends Controller
|
||||
$destination = $destinations->first();
|
||||
if ($type === 'public') {
|
||||
$validationRules = [
|
||||
'git_repository' => 'string|required',
|
||||
'git_branch' => 'string|required',
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'docker_compose_location' => 'string',
|
||||
@@ -883,7 +885,7 @@ class ApplicationsController extends Controller
|
||||
$application->source_type = GithubApp::class;
|
||||
$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->destination_id = $destination->id;
|
||||
$application->destination_type = $destination->getMorphClass();
|
||||
@@ -935,7 +937,7 @@ class ApplicationsController extends Controller
|
||||
} elseif ($type === 'private-gh-app') {
|
||||
$validationRules = [
|
||||
'git_repository' => 'string|required',
|
||||
'git_branch' => 'string|required',
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'github_app_uuid' => 'string|required',
|
||||
@@ -1043,7 +1045,7 @@ class ApplicationsController extends Controller
|
||||
$application->docker_compose_domains = $dockerComposeDomainsJson;
|
||||
}
|
||||
$application->fqdn = $fqdn;
|
||||
$application->git_repository = $gitRepository;
|
||||
$application->git_repository = str($gitRepository)->trim()->toString();
|
||||
$application->destination_id = $destination->id;
|
||||
$application->destination_type = $destination->getMorphClass();
|
||||
$application->environment_id = $environment->id;
|
||||
@@ -1090,8 +1092,8 @@ class ApplicationsController extends Controller
|
||||
} elseif ($type === 'private-deploy-key') {
|
||||
|
||||
$validationRules = [
|
||||
'git_repository' => 'string|required',
|
||||
'git_branch' => 'string|required',
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'private_key_uuid' => 'string|required',
|
||||
|
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
@@ -155,6 +156,21 @@ class GithubPrivateRepository extends Component
|
||||
public function submit()
|
||||
{
|
||||
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 = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
@@ -171,8 +187,8 @@ class GithubPrivateRepository extends Component
|
||||
$application = Application::create([
|
||||
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
|
||||
'repository_project_id' => $this->selected_repository_id,
|
||||
'git_repository' => "{$this->selected_repository_owner}/{$this->selected_repository_repo}",
|
||||
'git_branch' => $this->selected_branch_name,
|
||||
'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
|
||||
'git_branch' => str($this->selected_branch_name)->trim()->toString(),
|
||||
'build_pack' => $this->build_pack,
|
||||
'ports_exposes' => $this->port,
|
||||
'publish_directory' => $this->publish_directory,
|
||||
|
@@ -9,6 +9,8 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -53,17 +55,29 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
|
||||
private ?string $git_host = null;
|
||||
|
||||
private string $git_repository;
|
||||
private ?string $git_repository = null;
|
||||
|
||||
protected $rules = [
|
||||
'repository_url' => 'required',
|
||||
'branch' => 'required|string',
|
||||
'repository_url' => ['required', 'string'],
|
||||
'branch' => ['required', 'string'],
|
||||
'port' => 'required|numeric',
|
||||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|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 = [
|
||||
'repository_url' => 'Repository',
|
||||
'branch' => 'Branch',
|
||||
@@ -135,6 +149,9 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
|
||||
$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();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
if ($this->git_source === 'other') {
|
||||
@@ -194,6 +211,15 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
|
||||
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->git_host = $this->repository_url_parsed->getHost();
|
||||
$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')) {
|
||||
$this->git_host = $this->repository_url_parsed->getHost();
|
||||
$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');
|
||||
} else {
|
||||
// If it's already in SSH format, just use it as-is
|
||||
$this->git_repository = $this->repository_url;
|
||||
}
|
||||
$this->git_source = 'other';
|
||||
|
@@ -9,6 +9,8 @@ use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -62,7 +64,7 @@ class PublicGitRepository extends Component
|
||||
public bool $new_compose_services = false;
|
||||
|
||||
protected $rules = [
|
||||
'repository_url' => 'required|url',
|
||||
'repository_url' => ['required', 'string'],
|
||||
'port' => 'required|numeric',
|
||||
'isStatic' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
@@ -71,6 +73,20 @@ class PublicGitRepository extends Component
|
||||
'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 = [
|
||||
'repository_url' => 'repository',
|
||||
'port' => 'port',
|
||||
@@ -141,6 +157,15 @@ class PublicGitRepository extends Component
|
||||
public function loadBranch()
|
||||
{
|
||||
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@')) {
|
||||
$github_instance = str($this->repository_url)->after('git@')->before(':');
|
||||
$repository = str($this->repository_url)->after(':')->before('.git');
|
||||
@@ -191,6 +216,15 @@ class PublicGitRepository extends Component
|
||||
$this->git_branch = 'main';
|
||||
$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->git_host = $this->repository_url_parsed->getHost();
|
||||
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
|
||||
@@ -234,6 +268,27 @@ class PublicGitRepository extends Component
|
||||
{
|
||||
try {
|
||||
$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'];
|
||||
$project_uuid = $this->parameters['project_uuid'];
|
||||
$environment_uuid = $this->parameters['environment_uuid'];
|
||||
|
@@ -1128,15 +1128,20 @@ class Application extends BaseModel
|
||||
$branch = $this->git_branch;
|
||||
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
|
||||
$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([]);
|
||||
|
||||
// Check if shallow clone is enabled
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
$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) {
|
||||
$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) {
|
||||
$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->is_public) {
|
||||
$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) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
}
|
||||
@@ -1162,11 +1168,15 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
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}";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$repoUrl = "$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 {
|
||||
$git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$repoUrl = "$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) {
|
||||
$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";
|
||||
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
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 {
|
||||
$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.');
|
||||
}
|
||||
$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) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
|
96
app/Rules/ValidGitBranch.php
Normal file
96
app/Rules/ValidGitBranch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
157
app/Rules/ValidGitRepositoryUrl.php
Normal file
157
app/Rules/ValidGitRepositoryUrl.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<h1>Create a new Application</h1>
|
||||
<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')
|
||||
<h2 class="pb-4">Select a private key</h2>
|
||||
<div class="flex flex-col justify-center gap-2 text-left ">
|
||||
@@ -46,9 +46,8 @@
|
||||
</div>
|
||||
@endif
|
||||
@if ($current_step === 'repository')
|
||||
<h2 class="pb-4">Select a repository</h2>
|
||||
<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@)" />
|
||||
<form class="flex flex-col gap-2" wire:submit='submit'>
|
||||
<x-forms.input id="repository_url" required label="Repository URL (https:// or git@)" />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input id="branch" required label="Branch" />
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
|
||||
<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 -->
|
||||
<form class="flex flex-col gap-2" wire:submit='loadBranch'>
|
||||
|
Reference in New Issue
Block a user