allowSSH = $allowSSH; $this->allowIP = $allowIP; $this->allowRepositoryPath = $allowRepositoryPath; } /** * Run the validation rule. */ public function validate(string $attribute, mixed $value, Closure $fail): void { if (empty($value)) { return; } // Brackets are only allowed around an IPv6 host, so check the rest of the URL without it. $valueWithoutIpv6Host = preg_replace_callback( '/^((?:https?|git):\/\/|(?:ssh:\/\/)?[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/', fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0], $value, ); // Git decodes %XX sequences, so check the decoded URL. A '%' that is left after decoding // is a malformed or double-encoded sequence and is rejected with the other characters. $decodedValue = rawurldecode($valueWithoutIpv6Host); // Control characters (newline, null byte, tab) can inject git credential-helper lines (CVE-2020-5260) if (preg_match('/[\x00-\x1F\x7F]/', $decodedValue)) { Log::warning('Git repository URL validation failed - control character', [ 'url' => $value, 'ip' => request()->ip(), 'user_id' => auth()->id(), ]); $fail('The :attribute contains invalid characters.'); 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($decodedValue, $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 '/< $value, 'pattern' => $pattern, 'ip' => request()->ip(), 'user_id' => auth()->id(), ]); $fail('The :attribute contains invalid patterns.'); return; } } // Validate based on URL type if (preg_match('/^[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+):/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); return; } // Validate scp-style SSH URL format (user@host:user/repo.git) $scp = parseScpStyleGitUrl($value); if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~%]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); return; } } elseif (str_starts_with($value, 'ssh://')) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); return; } // Validate ssh:// URL format (ssh://user@host[:port]/user/repo.git) if (! preg_match('/^ssh:\/\/[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)(:[0-9]{1,5})?\/[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); // parse_url() keeps the brackets around an IPv6 host. $host = trim(strtolower($parsed['host'] ?? ''), '[]'); // Check for IP addresses if not allowed if (! $this->allowIP && filter_var($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 $internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; $isReservedIp = filter_var($host, FILTER_VALIDATE_IP) !== false && filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false; if (in_array($host, $internalHosts) || $isReservedIp || 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; } // Percent-encoding is only allowed in the path, because hosts and credentials can be decoded differently if (str_contains(($parsed['host'] ?? '').($parsed['user'] ?? '').($parsed['pass'] ?? ''), '%')) { $fail('The :attribute contains invalid characters.'); 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 (supports both git://host/path and git://host:port/path with tilde) if (! preg_match('/^git:\/\/(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9\.\-]+)(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~%]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; } } elseif ($this->allowRepositoryPath && preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9._-]*(\/[a-zA-Z0-9._-]+)+$/', $value) === 1) { // A repository path (owner/name, or group/subgroup/name) that the Git source turns into a URL. return; } else { $fail('The :attribute must start with https://, http://, git://, ssh://, or be an SSH URL (user@host:path).'); return; } } }