PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
thinkrank / includes / core / class-url-validator.php

class-url-validator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/core/class-url-validator.php

221 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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