| @@ -959,8 +959,32 @@ | ||
| 959 | 959 | } |
| 960 | 960 | |
| 961 | 961 | return $normalized; |
| 962 | 962 | } |
| 963 | + /** | |
| 964 | + * The URL to check and fetch for a competitor URL the user entered, or | |
| 965 | + * null when it is not a valid URL. | |
| 966 | + * | |
| 967 | + * A competitor page on an internationalised domain, or with a Bengali or | |
| 968 | + * Arabic slug, is a valid URL, so the syntax check is Url_Validator's. | |
| 969 | + * What comes back is the ASCII form (punycode host, percent-encoded path), | |
| 970 | + * and both the SSRF guard and the fetch use it: the guard cannot resolve | |
| 971 | + * a Unicode host name, and it must check exactly the URL that is then | |
| 972 | + * requested. | |
| 973 | + * | |
| 974 | + * @since 2.14.2 | |
| 975 | + * | |
| 976 | + * @param string $url Trimmed URL as entered. | |
| 977 | + * @return string|null ASCII URL, or null when invalid. | |
| 978 | + */ | |
| 979 | + private function competitor_fetch_url(string $url): ?string { | |
| 980 | + if ('' === $url || !\ThinkRank\Core\Url_Validator::is_valid($url)) { | |
| 981 | + return null; | |
| 982 | + } | |
| 983 | + | |
| 984 | + return \ThinkRank\Core\Url_Validator::to_ascii($url); | |
| 985 | + } | |
| 986 | + | |
| 963 | 987 | private function analyze_competitor_urls(array $urls): string { |
| 964 | 988 | $analysis_results = []; |
| 965 | 989 | $failed_urls = []; |
| 966 | 990 | |
| @@ -968,9 +992,10 @@ | ||
| 968 | 992 | $urls = array_slice($urls, 0, 3); |
| 969 | 993 | |
| 970 | 994 | foreach ($urls as $url) { |
| 971 | 995 | $url = trim($url); |
| 972 | - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { | |
| 996 | + $fetch_url = $this->competitor_fetch_url($url); | |
| 997 | + if (null === $fetch_url) { | |
| 973 | 998 | $failed_urls[] = $url . " (invalid URL)"; |
| 974 | 999 | continue; |
| 975 | 1000 | } |
| 976 | 1001 | |
| @@ -976,14 +1001,14 @@ | ||
| 976 | 1001 | |
| 977 | 1002 | // SSRF guard: only fetch public http/https hosts. Blocks loopback, |
| 978 | 1003 | // link-local (cloud metadata), private and reserved ranges before any |
| 979 | 1004 | // request is made. |
| 980 | - if (!$this->is_safe_public_url($url)) { | |
| 1005 | + if (!$this->is_safe_public_url($fetch_url)) { | |
| 981 | 1006 | $failed_urls[] = $url . " (blocked: non-public host)"; |
| 982 | 1007 | continue; |
| 983 | 1008 | } |
| 984 | 1009 | |
| 985 | - $content_data = $this->scrape_competitor_content($url); | |
| 1010 | + $content_data = $this->scrape_competitor_content($fetch_url); | |
| 986 | 1011 | if ($content_data) { |
| 987 | 1012 | $analysis_results[] = $this->format_competitor_analysis($url, $content_data); |
| 988 | 1013 | } else { |
| 989 | 1014 | $failed_urls[] = $url . " (failed to scrape)"; |
| @@ -1229,9 +1254,9 @@ | ||
| 1229 | 1254 | // Add heading structure |
| 1230 | 1255 | if (!empty($content_data['headings'])) { |
| 1231 | 1256 | $analysis .= "\nCONTENT STRUCTURE:\n"; |
| 1232 | 1257 | foreach ($content_data['headings'] as $level => $headings) { |
| 1233 | - $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3)); | |
| 1258 | + $analysis .= "- " . strtoupper($level) . " (" . count($headings) . "): " . implode(', ', array_slice($headings, 0, 3)); | |
| 1234 | 1259 | if (count($headings) > 3) { |
| 1235 | 1260 | $analysis .= "... (+" . (count($headings) - 3) . " more)"; |
| 1236 | 1261 | } |
| 1237 | 1262 | $analysis .= "\n"; |