PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / Reachability.php

Reachability.php in Extendify 3.2.2, at app/Mcp/Reachability.php

102 lines 3.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Whether an MCP client outside this site could reach its endpoint.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Mcp\OAuth\Metadata;
12
13 /**
14 * DNS is never resolved: a host's own view of its domain is often a private address.
15 */
16 class Reachability
17 {
18 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
19 const LOCAL_SUFFIXES = '/(^|\.)(localhost|local|test|internal|lan|home|home\.arpa|invalid|example)$/i';
20
21 /**
22 * @return string|null local, http, auth or blocked; null when nothing stands in the way.
23 */
24 public static function obstacle()
25 {
26 $url = Metadata::resource();
27 $parts = \wp_parse_url($url);
28
29 if (self::isLocal(trim((string) ($parts['host'] ?? ''), '[]'))) {
30 return 'local';
31 }
32
33 if (($parts['scheme'] ?? '') !== 'https') {
34 return 'http';
35 }
36
37 return self::loopback($url);
38 }
39
40 /**
41 * @param string $host - The host part of the site's address, brackets stripped.
42 * @return boolean
43 */
44 private static function isLocal($host)
45 {
46 if (filter_var($host, FILTER_VALIDATE_IP)) {
47 return !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
48 }
49
50 return strpos($host, '.') === false || (bool) preg_match(self::LOCAL_SUFFIXES, $host);
51 }
52
53 /**
54 * Many hosts cannot reach themselves, and some answer a self-request with an empty 200.
55 * The verdict is kept for a few minutes: the request has a five-second timeout and runs on every page load.
56 *
57 * @param string $url - The endpoint as a client would see it.
58 * @return string|null
59 */
60 private static function loopback($url)
61 {
62 $key = 'extendify_mcp_reach_' . md5($url);
63 $kept = \get_transient($key);
64 if (is_array($kept)) {
65 return $kept['obstacle'];
66 }
67
68 $obstacle = self::ask($url);
69 \set_transient($key, ['obstacle' => $obstacle], 5 * MINUTE_IN_SECONDS);
70
71 return $obstacle;
72 }
73
74 /**
75 * @param string $url - The endpoint as a client would see it.
76 * @return string|null
77 */
78 private static function ask($url)
79 {
80 $response = \wp_remote_post($url, [
81 'timeout' => 5,
82 'sslverify' => false,
83 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
84 'body' => \wp_json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize']),
85 ]);
86 $raw = \is_wp_error($response) ? '' : \wp_remote_retrieve_body($response);
87 if (trim($raw) === '') {
88 return null;
89 }
90
91 $body = json_decode($raw, true);
92 if (($body['code'] ?? '') === 'extendify_mcp_unauthorized') {
93 return null;
94 }
95
96 $challenged = \wp_remote_retrieve_response_code($response) === 401
97 && \wp_remote_retrieve_header($response, 'www-authenticate');
98
99 return $challenged ? 'auth' : 'blocked';
100 }
101 }
102