Blocks
4 months ago
Contracts
9 months ago
Errors
1 month ago
Scripts
3 days ago
Arrays.php
3 years ago
ColorService.php
3 years ago
Currency.php
1 year ago
Encryption.php
3 years ago
GitHubInstaller.php
3 days ago
Interval.php
7 months ago
Server.php
2 years ago
TimeDate.php
3 months ago
Translations.php
3 years ago
URL.php
2 years ago
UtilityService.php
3 years ago
UtilityServiceProvider.php
3 years ago
kses.json
3 days ago
GitHubInstaller.php
161 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SureCart\Support; |
| 4 | |
| 5 | /** |
| 6 | * Validates GitHub-hosted plugin/theme zip URLs before they are used for installation. |
| 7 | * |
| 8 | * Acts as an allow-list gate so only trusted GitHub download hosts and installable |
| 9 | * paths are accepted. This is a structural pre-flight check, not a replacement for |
| 10 | * a runtime SSRF guard. |
| 11 | */ |
| 12 | class GitHubInstaller { |
| 13 | /** |
| 14 | * Hosts that are permitted to serve installable zips. |
| 15 | * |
| 16 | * Matched exactly (lowercased) — no suffix or substring matching. |
| 17 | * |
| 18 | * @var array |
| 19 | */ |
| 20 | protected static $allowed_hosts = array( |
| 21 | 'github.com', |
| 22 | 'codeload.github.com', |
| 23 | 'objects.githubusercontent.com', |
| 24 | 'release-assets.githubusercontent.com', |
| 25 | ); |
| 26 | |
| 27 | /** |
| 28 | * Validate a URL and return a trusted GitHub zip download URL. |
| 29 | * |
| 30 | * @param string $url The candidate download URL. |
| 31 | * |
| 32 | * @return string|\WP_Error The trimmed URL on success, or a WP_Error describing the failure. |
| 33 | */ |
| 34 | public static function resolveZipUrl( string $url ) { |
| 35 | $url = trim( $url ); |
| 36 | |
| 37 | if ( empty( $url ) ) { |
| 38 | return new \WP_Error( 'sc_invalid_url', __( 'Please provide a download URL.', 'surecart' ) ); |
| 39 | } |
| 40 | |
| 41 | // Cheap pre-filter — catches obviously malformed input before deeper checks. |
| 42 | if ( ! wp_http_validate_url( $url ) ) { |
| 43 | return new \WP_Error( 'sc_invalid_url', __( 'The download URL is not valid.', 'surecart' ) ); |
| 44 | } |
| 45 | |
| 46 | $parts = wp_parse_url( $url ); |
| 47 | |
| 48 | if ( empty( $parts['scheme'] ) || 'https' !== $parts['scheme'] ) { |
| 49 | return new \WP_Error( 'sc_invalid_url', __( 'The download URL must use HTTPS.', 'surecart' ) ); |
| 50 | } |
| 51 | |
| 52 | // Reject non-standard ports. |
| 53 | if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) { |
| 54 | return new \WP_Error( 'sc_invalid_url', __( 'The download URL must not specify a custom port.', 'surecart' ) ); |
| 55 | } |
| 56 | |
| 57 | // Reject embedded credentials (e.g. https://x@github.com@evil/...). |
| 58 | if ( ! empty( $parts['user'] ) || ! empty( $parts['pass'] ) ) { |
| 59 | return new \WP_Error( 'sc_invalid_url', __( 'The download URL must not contain user credentials.', 'surecart' ) ); |
| 60 | } |
| 61 | |
| 62 | if ( empty( $parts['host'] ) ) { |
| 63 | return new \WP_Error( 'sc_invalid_host', __( 'The download URL host is not allowed.', 'surecart' ) ); |
| 64 | } |
| 65 | |
| 66 | $host = strtolower( $parts['host'] ); |
| 67 | |
| 68 | if ( ! self::isAllowedHost( $host ) ) { |
| 69 | return new \WP_Error( 'sc_invalid_host', __( 'Only GitHub download URLs are allowed.', 'surecart' ) ); |
| 70 | } |
| 71 | |
| 72 | $path = isset( $parts['path'] ) ? $parts['path'] : ''; |
| 73 | |
| 74 | if ( 'github.com' === $host ) { |
| 75 | $valid = self::isValidGitHubPath( $path ); |
| 76 | if ( is_wp_error( $valid ) ) { |
| 77 | return $valid; |
| 78 | } |
| 79 | } else { |
| 80 | $valid = self::isValidArchivePath( $path ); |
| 81 | if ( is_wp_error( $valid ) ) { |
| 82 | return $valid; |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | return $url; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Check whether a host is on the installable allow-list. |
| 91 | * |
| 92 | * Matched exactly (lowercased) — no suffix or substring matching. Exposed so |
| 93 | * the install pipeline can re-validate redirect targets against the same list. |
| 94 | * |
| 95 | * @param string $host The host to check. |
| 96 | * |
| 97 | * @return bool |
| 98 | */ |
| 99 | public static function isAllowedHost( string $host ): bool { |
| 100 | return in_array( strtolower( $host ), self::$allowed_hosts, true ); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Validate the path shape for a github.com download URL. |
| 105 | * |
| 106 | * Accepts release assets and any path ending in .zip; rejects bare repo URLs. |
| 107 | * |
| 108 | * @param string $path The URL path component. |
| 109 | * |
| 110 | * @return true|\WP_Error True when installable, otherwise a WP_Error. |
| 111 | */ |
| 112 | protected static function isValidGitHubPath( string $path ) { |
| 113 | $segment = '[A-Za-z0-9._-]+'; |
| 114 | |
| 115 | // /{owner}/{repo}/releases/latest/download/{file}.zip |
| 116 | if ( preg_match( '#^/' . $segment . '/' . $segment . '/releases/latest/download/' . $segment . '\.zip$#', $path ) ) { |
| 117 | return true; |
| 118 | } |
| 119 | |
| 120 | // /{owner}/{repo}/releases/download/{tag}/{file}.zip |
| 121 | if ( preg_match( '#^/' . $segment . '/' . $segment . '/releases/download/' . $segment . '/' . $segment . '\.zip$#', $path ) ) { |
| 122 | return true; |
| 123 | } |
| 124 | |
| 125 | // Any other github.com path that explicitly points at a .zip file. |
| 126 | if ( '.zip' === strtolower( substr( $path, -4 ) ) ) { |
| 127 | return true; |
| 128 | } |
| 129 | |
| 130 | return new \WP_Error( |
| 131 | 'sc_not_a_zip', |
| 132 | __( 'Please provide a GitHub release .zip URL (for example, .../releases/latest/download/plugin.zip) rather than a repository URL.', 'surecart' ) |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Validate the path shape for githubusercontent.com and codeload.github.com URLs. |
| 138 | * |
| 139 | * Requires a .zip suffix, except for codeload archive paths which serve zips |
| 140 | * without an extension (e.g. /{owner}/{repo}/zip/refs/...). |
| 141 | * |
| 142 | * @param string $path The URL path component. |
| 143 | * |
| 144 | * @return true|\WP_Error True when installable, otherwise a WP_Error. |
| 145 | */ |
| 146 | protected static function isValidArchivePath( string $path ) { |
| 147 | if ( '.zip' === strtolower( substr( $path, -4 ) ) ) { |
| 148 | return true; |
| 149 | } |
| 150 | |
| 151 | $segment = '[A-Za-z0-9._-]+'; |
| 152 | |
| 153 | // codeload archive path: /{owner}/{repo}/zip/refs/... |
| 154 | if ( preg_match( '#^/' . $segment . '/' . $segment . '/zip/#', $path ) ) { |
| 155 | return true; |
| 156 | } |
| 157 | |
| 158 | return new \WP_Error( 'sc_not_a_zip', __( 'The download URL must point to a .zip file.', 'surecart' ) ); |
| 159 | } |
| 160 | } |
| 161 |