PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wp-media / mcp-oauth / inc / Auth / Discovery / HealthCheck.php

HealthCheck.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wp-media/mcp-oauth/inc/Auth/Discovery/HealthCheck.php

273 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site Health self-check for the .well-known OAuth discovery documents.
4 *
5 * On hosts that provision a physical `.well-known/acme-challenge/` directory
6 * for Let's Encrypt auto-SSL (OVH, cPanel/AutoSSL, Plesk, and most managed-WP
7 * hosts), the web server can 404 sibling `.well-known/oauth-*` paths before
8 * WordPress ever runs — see the README "Hosting: .well-known conflicts"
9 * section. No WordPress-level code change can influence that; this class is
10 * a passive diagnostic only, registered as a single `direct` WordPress Site
11 * Health test (the `site_status_tests` filter) that issues a loopback request
12 * to both RFC discovery documents (`Endpoints`) and reports one combined
13 * status.
14 */
15
16 declare(strict_types=1);
17
18 namespace WPMedia\MCP\OAuth\Auth\Discovery;
19
20 use WP_Error;
21 use WPMedia\MCP\OAuth\Context;
22
23 class HealthCheck {
24
25 /**
26 * Site Health test key registered under the `direct` bucket.
27 */
28 const TEST_KEY = 'wpmedia_mcp_oauth_wellknown_discovery';
29
30 /**
31 * Transient key caching the combined self-check result.
32 */
33 const TRANSIENT_KEY = 'wpmedia_mcp_oauth_wellknown_health_check';
34
35 /**
36 * Cache TTL, in seconds, for the combined self-check result.
37 */
38 const TRANSIENT_TTL = 5 * MINUTE_IN_SECONDS;
39
40 /**
41 * Per-request timeout, in seconds, for each loopback discovery request.
42 */
43 const FETCH_TIMEOUT = 5;
44
45 /**
46 * Relative discovery document paths, keyed by a short display name.
47 *
48 * @var array<string, string>
49 */
50 private const DOCUMENTS = [
51 'oauth-protected-resource' => '/.well-known/oauth-protected-resource',
52 'oauth-authorization-server' => '/.well-known/oauth-authorization-server',
53 ];
54
55 /**
56 * Status severity used to pick the combined result.
57 *
58 * @var array<string, int>
59 */
60 private const STATUS_RANK = [
61 'good' => 0,
62 'recommended' => 1,
63 'critical' => 2,
64 ];
65
66 /**
67 * OAuth server context.
68 *
69 * @var Context
70 */
71 private Context $context;
72
73 /**
74 * Constructor.
75 *
76 * @param Context $context OAuth server context.
77 */
78 public function __construct( Context $context ) {
79 $this->context = $context;
80 }
81
82 /**
83 * Register the combined discovery-document self-check as a `direct` Site Health test.
84 *
85 * @param array<string, array<string, array<string, mixed>>> $tests Existing Site Health tests, keyed by bucket ('direct'|'async').
86 * @return array<string, array<string, array<string, mixed>>> Modified tests.
87 */
88 public function add_test( array $tests ): array {
89 $tests['direct'][ self::TEST_KEY ] = [
90 'label' => __( 'MCP OAuth discovery documents', 'mcp-oauth' ),
91 'test' => [ $this, 'run_self_check' ],
92 ];
93
94 return $tests;
95 }
96
97 /**
98 * Run the combined discovery-document self-check.
99 *
100 * @return array<string, mixed> Site Health test result (label, status, badge, description, actions, test).
101 */
102 public function run_self_check(): array {
103 if ( ! $this->context->is_enabled() ) {
104 return $this->build_result(
105 'good',
106 [],
107 __( 'The MCP OAuth server is intentionally disabled (the <code>wpmedia_mcp_oauth_server_enabled</code> filter returns false), so the .well-known discovery documents are not expected to be reachable.', 'mcp-oauth' )
108 );
109 }
110
111 if ( '' === (string) get_option( 'permalink_structure' ) ) {
112 return $this->build_result(
113 'recommended',
114 [],
115 __( 'The .well-known OAuth discovery documents are served through a rewrite rule, which requires a pretty permalink structure. Enable one under Settings → Permalinks, then re-check this test.', 'mcp-oauth' )
116 );
117 }
118
119 $cached = get_transient( self::TRANSIENT_KEY );
120 if ( is_array( $cached ) ) {
121 return $cached;
122 }
123
124 $worst_status = 'good';
125 $failing = [];
126
127 foreach ( self::DOCUMENTS as $name => $path ) {
128 $response = wp_safe_remote_get(
129 home_url( $path ),
130 [
131 'timeout' => self::FETCH_TIMEOUT,
132 ]
133 );
134
135 $status = $this->classify( $response );
136
137 if ( self::STATUS_RANK[ $status ] > self::STATUS_RANK[ $worst_status ] ) {
138 $worst_status = $status;
139 }
140
141 if ( 'good' !== $status ) {
142 $failing[] = $name;
143 }
144 }
145
146 $result = $this->build_result( $worst_status, $failing );
147
148 set_transient( self::TRANSIENT_KEY, $result, self::TRANSIENT_TTL );
149
150 return $result;
151 }
152
153 /**
154 * Classify a single document's fetch result into a Site Health status.
155 *
156 * @param array<string, mixed>|WP_Error $response The wp_remote_get() response array, or a WP_Error.
157 * @return string One of 'good', 'recommended', 'critical'.
158 */
159 private function classify( $response ): string {
160 if ( is_wp_error( $response ) ) {
161 return 'recommended';
162 }
163
164 $status_code = (int) wp_remote_retrieve_response_code( $response );
165
166 if ( in_array( $status_code, [ 401, 403, 407 ], true ) ) {
167 return 'recommended';
168 }
169
170 if ( 404 === $status_code ) {
171 $powered_by = (string) wp_remote_retrieve_header( $response, 'x-powered-by' );
172 $redirect_by = (string) wp_remote_retrieve_header( $response, 'x-redirect-by' );
173
174 if ( '' === $powered_by && false === stripos( $redirect_by, 'WordPress' ) ) {
175 return 'critical';
176 }
177
178 return 'recommended';
179 }
180
181 if ( 200 === $status_code ) {
182 $body = (string) wp_remote_retrieve_body( $response );
183 $decoded = json_decode( $body, true );
184
185 if ( is_array( $decoded ) ) {
186 return 'good';
187 }
188 }
189
190 return 'recommended';
191 }
192
193 /**
194 * Build the Site Health result array for a given combined status.
195 *
196 * @param string $status One of 'good', 'recommended', 'critical'.
197 * @param string[] $failing Display names of the documents that did not report 'good'.
198 * @param string $summary Optional override for the description's leading sentence
199 * (used by the disabled/plain-permalinks short-circuits).
200 * @return array<string, mixed>
201 */
202 private function build_result( string $status, array $failing, string $summary = '' ): array {
203 $loopback_caveat = __( 'This check runs from the server to itself. A "Good" result here does not guarantee external clients can reach these documents: a CDN, WAF, or reverse proxy in front of the site can still 404 these paths for external traffic while the server\'s own loopback request bypasses it. Verify with an external <code>curl</code> request after applying any server-config change.', 'mcp-oauth' );
204
205 /*
206 * The acme-challenge fingerprint is classified 'critical' internally so
207 * the detailed guidance below is selected, but it is reported to Site
208 * Health as 'recommended': the .well-known discovery documents are an
209 * optional MCP feature, and a "critical" badge generates support tickets
210 * for what is a non-blocking misconfiguration.
211 */
212 $reported_status = 'critical' === $status ? 'recommended' : $status;
213
214 if ( '' !== $summary ) {
215 $description = sprintf( '<p>%s</p>', $summary );
216 $actions = sprintf( '<p>%s</p>', $loopback_caveat );
217 } elseif ( 'good' === $status ) {
218 $description = sprintf(
219 '<p>%s</p>',
220 __( 'Both .well-known OAuth discovery documents (oauth-protected-resource and oauth-authorization-server) responded with HTTP 200 and valid JSON.', 'mcp-oauth' )
221 );
222 $actions = sprintf( '<p>%s</p>', $loopback_caveat );
223 } else {
224 $document_list = implode( ', ', $failing );
225
226 if ( 'critical' === $status ) {
227 $description = sprintf(
228 '<p>%s</p>',
229 sprintf(
230 /* translators: %s: comma-separated list of failing discovery document names. */
231 __( 'The following .well-known discovery document(s) returned a bare 404 with no WordPress-originated response header: %s. This matches the fingerprint of a physical .well-known/acme-challenge/ directory (provisioned by the host for Let\'s Encrypt auto-SSL) intercepting the request before WordPress runs — likely the cause here, though this should be confirmed against the actual .well-known/acme-challenge/ directory on the server before concluding root cause.', 'mcp-oauth' ),
232 esc_html( $document_list )
233 )
234 );
235
236 $actions = sprintf(
237 '<p>%s</p><p>%s</p><p>%s</p>',
238 __( 'Apply the Apache or Nginx snippet from this library\'s README ("Hosting: .well-known conflicts") to re-enable routing for these two paths without touching acme-challenge/.', 'mcp-oauth' ),
239 __( 'If a CDN or page cache is in front of the site, purge it after applying the fix — a stale cached 404 for these paths can otherwise persist even once the server config is corrected.', 'mcp-oauth' ),
240 $loopback_caveat
241 );
242 } else {
243 $description = sprintf(
244 '<p>%s</p>',
245 sprintf(
246 /* translators: %s: comma-separated list of failing discovery document names. */
247 __( 'The following .well-known discovery document(s) did not respond as expected: %s. This does not match the confirmed .well-known/acme-challenge/ interception fingerprint, so the cause is inconclusive from this check alone (it may be a timeout, an access wall such as HTTP Basic Auth or a WAF, or a WordPress-served error).', 'mcp-oauth' ),
248 esc_html( $document_list )
249 )
250 );
251
252 $actions = sprintf(
253 '<p>%s</p><p>%s</p>',
254 __( 'Check your error/access logs for the requests to these paths, and rule out an authentication wall (staging HTTP Basic Auth, a WAF, or bot mitigation) before assuming a routing problem.', 'mcp-oauth' ),
255 $loopback_caveat
256 );
257 }
258 }
259
260 return [
261 'label' => __( 'MCP OAuth discovery documents', 'mcp-oauth' ),
262 'status' => $reported_status,
263 'badge' => [
264 'label' => __( 'Configuration', 'mcp-oauth' ),
265 'color' => 'blue',
266 ],
267 'description' => $description,
268 'actions' => $actions,
269 'test' => self::TEST_KEY,
270 ];
271 }
272 }
273