Ctrl K
ComunidadGuías…

app/Rules/ValidGitRepositoryUrl.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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;

    protected bool $allowRepositoryPath;

    /**
     * @param  bool  $allowRepositoryPath  Also accept a repository path such as `owner/name`, which applications with a Git source store.
     */
    public function __construct(bool $allowSSH = true, bool $allowIP = true, bool $allowRepositoryPath = false)
    {
        $this->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
            '/<</',        // Here document
            '/\\\n/',      // Line continuation
            '/\.\.[\/\\\\]/', // Directory traversal
        ];

        foreach ($dangerousPatterns as $pattern) {
            if (preg_match($pattern, $decodedValue)) {
                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 (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;
        }
    }
}