| 1 |
<?php |
| 2 |
/** |
| 3 |
* IRI-aware URL syntax validation. |
| 4 |
* |
| 5 |
* @package ThinkRank |
| 6 |
* @subpackage Core |
| 7 |
* @since 2.14.2 |
| 8 |
*/ |
| 9 |
|
| 10 |
declare(strict_types=1); |
| 11 |
|
| 12 |
namespace ThinkRank\Core; |
| 13 |
|
| 14 |
if (!defined('ABSPATH')) { |
| 15 |
exit; |
| 16 |
} |
| 17 |
|
| 18 |
/** |
| 19 |
* Validates URLs the way the web uses them, non-ASCII characters included. |
| 20 |
* |
| 21 |
* PHP's FILTER_VALIDATE_URL implements RFC 2396 and refuses any byte outside |
| 22 |
* ASCII. WordPress keeps non-Latin characters in uploaded filenames and does |
| 23 |
* not percent-encode attachment URLs, so on a site with a Bengali, Arabic or |
| 24 |
* Chinese media library the raw filter refused real images and logos (#924). |
| 25 |
* Those URLs are valid IRIs; browsers, search engines and schema.org accept |
| 26 |
* them. |
| 27 |
* |
| 28 |
* Every content-URL syntax check in ThinkRank (and ThinkRank Pro) goes through |
| 29 |
* here instead of calling filter_var() directly. The check maps the IRI to its |
| 30 |
* URI form first (IDN host to punycode, every other non-ASCII byte to its %XX |
| 31 |
* escape) and then runs the unchanged PHP filter, so anything that was invalid |
| 32 |
* before for an ASCII reason (a literal space, a control character, a missing |
| 33 |
* scheme) is still invalid. |
| 34 |
* |
| 35 |
* This is a syntax check only. It is not an SSRF guard; server-side fetches of |
| 36 |
* user-supplied URLs still belong to {@see Url_Safety}. |
| 37 |
* |
| 38 |
* @since 2.14.2 |
| 39 |
*/ |
| 40 |
final class Url_Validator { |
| 41 |
|
| 42 |
/** |
| 43 |
* Map an IRI to its ASCII URI form. |
| 44 |
* |
| 45 |
* An internationalised host becomes punycode when the intl extension is |
| 46 |
* available (left untouched otherwise, which then fails validation exactly |
| 47 |
* as it did before). Every byte above 0x7F elsewhere becomes its %XX |
| 48 |
* escape, which is the RFC 3987 mapping for UTF-8 input. ASCII bytes are |
| 49 |
* never touched, so an already-encoded URL comes back unchanged and is not |
| 50 |
* double-encoded. |
| 51 |
* |
| 52 |
* Also the form to write where a protocol demands an escaped URL, such as |
| 53 |
* the image sitemap's <image:loc>. |
| 54 |
* |
| 55 |
* @since 2.14.2 |
| 56 |
* |
| 57 |
* @param string $url URL or IRI. |
| 58 |
* @return string ASCII URL. |
| 59 |
*/ |
| 60 |
public static function to_ascii(string $url): string { |
| 61 |
if (!preg_match('/[\x80-\xFF]/', $url)) { |
| 62 |
return $url; |
| 63 |
} |
| 64 |
|
| 65 |
// scheme://[userinfo@]host[:port] — convert only the host to punycode. |
| 66 |
if (preg_match('#^([a-z][a-z0-9+.\-]*://)([^/?\#]*)(.*)$#is', $url, $m)) { |
| 67 |
$authority = $m[2]; |
| 68 |
$userinfo = ''; |
| 69 |
$at = strrpos($authority, '@'); |
| 70 |
if (false !== $at) { |
| 71 |
$userinfo = substr($authority, 0, $at + 1); |
| 72 |
$authority = substr($authority, $at + 1); |
| 73 |
} |
| 74 |
|
| 75 |
$port = ''; |
| 76 |
if (preg_match('/^(.*?)(:\d*)$/s', $authority, $hp)) { |
| 77 |
$authority = $hp[1]; |
| 78 |
$port = $hp[2]; |
| 79 |
} |
| 80 |
|
| 81 |
$host = $authority; |
| 82 |
if (preg_match('/[\x80-\xFF]/', $host) && function_exists('idn_to_ascii')) { |
| 83 |
$flags = defined('IDNA_NONTRANSITIONAL_TO_ASCII') ? IDNA_NONTRANSITIONAL_TO_ASCII : 0; |
| 84 |
$variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0; |
| 85 |
$ascii_host = idn_to_ascii($host, $flags, $variant); |
| 86 |
if (is_string($ascii_host) && '' !== $ascii_host) { |
| 87 |
$host = $ascii_host; |
| 88 |
} |
| 89 |
} |
| 90 |
|
| 91 |
$url = $m[1] . $userinfo . $host . $port . $m[3]; |
| 92 |
} |
| 93 |
|
| 94 |
return (string) preg_replace_callback( |
| 95 |
'/[\x80-\xFF]+/', |
| 96 |
static function (array $bytes): string { |
| 97 |
return rawurlencode($bytes[0]); |
| 98 |
}, |
| 99 |
$url |
| 100 |
); |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Whether a value is a syntactically valid absolute URL, IRIs included. |
| 105 |
* |
| 106 |
* Drop-in replacement for `filter_var($url, FILTER_VALIDATE_URL)`: same |
| 107 |
* verdict for every ASCII input, and no scheme restriction. Use |
| 108 |
* {@see self::is_http_url()} where only web URLs are acceptable. |
| 109 |
* |
| 110 |
* @since 2.14.2 |
| 111 |
* |
| 112 |
* @param mixed $url Candidate value. |
| 113 |
* @return bool |
| 114 |
*/ |
| 115 |
public static function is_valid($url): bool { |
| 116 |
if (!is_string($url) || '' === $url) { |
| 117 |
return false; |
| 118 |
} |
| 119 |
|
| 120 |
return false !== filter_var(self::to_ascii($url), FILTER_VALIDATE_URL); |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Whether a value is a valid absolute http or https URL, IRIs included. |
| 125 |
* |
| 126 |
* Rejects javascript:, data:, mailto: and every other scheme. |
| 127 |
* |
| 128 |
* @since 2.14.2 |
| 129 |
* |
| 130 |
* @param mixed $url Candidate value. |
| 131 |
* @return bool |
| 132 |
*/ |
| 133 |
public static function is_http_url($url): bool { |
| 134 |
if (!self::is_valid($url)) { |
| 135 |
return false; |
| 136 |
} |
| 137 |
|
| 138 |
$scheme = wp_parse_url((string) $url, PHP_URL_SCHEME); |
| 139 |
|
| 140 |
return is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true); |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* Whether a value is an http(s) URL or a path on this site. |
| 145 |
* |
| 146 |
* For settings that are written into a src or href and may reasonably |
| 147 |
* hold a root-relative path ("/wp-content/uploads/icon.png"). A path must |
| 148 |
* start with a single "/": "//host" and "/\host" are protocol-relative to |
| 149 |
* a browser and would load from another site. |
| 150 |
* |
| 151 |
* @since 2.14.2 |
| 152 |
* |
| 153 |
* @param mixed $url Candidate value. |
| 154 |
* @return bool |
| 155 |
*/ |
| 156 |
public static function is_http_url_or_path($url): bool { |
| 157 |
if (self::is_http_url($url)) { |
| 158 |
return true; |
| 159 |
} |
| 160 |
|
| 161 |
return is_string($url) |
| 162 |
&& 1 === preg_match('#^/(?![/\\\\])#', $url) |
| 163 |
&& 1 !== preg_match('/[\s\x00-\x1F\x7F]/', $url); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* A Search Console domain property in the form the API uses, or null. |
| 168 |
* |
| 169 |
* A domain property is "sc-domain:" followed by a bare hostname: no |
| 170 |
* scheme, port, path or userinfo. An internationalised name is accepted |
| 171 |
* and returned as punycode, lowercased, so "sc-domain:Bücher.example" and |
| 172 |
* "sc-domain:xn--bcher-kva.example" are one property. The name needs at |
| 173 |
* least two labels, each 1 to 63 letters, digits or hyphens that neither |
| 174 |
* starts nor ends with a hyphen. |
| 175 |
* |
| 176 |
* @since 2.14.2 |
| 177 |
* |
| 178 |
* @param mixed $value Candidate value. |
| 179 |
* @return string|null "sc-domain:<ascii host>", or null when it is not one. |
| 180 |
*/ |
| 181 |
public static function search_console_domain_property($value): ?string { |
| 182 |
if (!is_string($value) || 0 !== stripos($value, 'sc-domain:')) { |
| 183 |
return null; |
| 184 |
} |
| 185 |
|
| 186 |
$host = substr($value, strlen('sc-domain:')); |
| 187 |
|
| 188 |
if (preg_match('/[\x80-\xFF]/', $host)) { |
| 189 |
if (!function_exists('idn_to_ascii')) { |
| 190 |
return null; |
| 191 |
} |
| 192 |
$flags = defined('IDNA_NONTRANSITIONAL_TO_ASCII') ? IDNA_NONTRANSITIONAL_TO_ASCII : 0; |
| 193 |
$variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0; |
| 194 |
$ascii = idn_to_ascii($host, $flags, $variant); |
| 195 |
if (!is_string($ascii) || '' === $ascii) { |
| 196 |
return null; |
| 197 |
} |
| 198 |
$host = $ascii; |
| 199 |
} |
| 200 |
|
| 201 |
$host = strtolower($host); |
| 202 |
|
| 203 |
if (strlen($host) > 253) { |
| 204 |
return null; |
| 205 |
} |
| 206 |
|
| 207 |
$labels = explode('.', $host); |
| 208 |
if (count($labels) < 2) { |
| 209 |
return null; |
| 210 |
} |
| 211 |
|
| 212 |
foreach ($labels as $label) { |
| 213 |
if (1 !== preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $label)) { |
| 214 |
return null; |
| 215 |
} |
| 216 |
} |
| 217 |
|
| 218 |
return 'sc-domain:' . $host; |
| 219 |
} |
| 220 |
} |
| 221 |
|