PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-static-discovery.php

class-mcp-static-discovery.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.0, at includes/mcp/class-mcp-static-discovery.php

296 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Static OAuth discovery files — the /.well-known/ escape hatch.
4 *
5 * Some hosts (SiteGround shared hosting confirmed, #374) resolve every
6 * request under the site root's /.well-known/ directory at their Nginx edge
7 * as a physical path. No rewrite, no Apache, no WordPress — a missing file is
8 * a server-level 404, and no amount of permalink flushing can fix it because
9 * the request never reaches PHP.
10 *
11 * The primary mitigation is that the 401 challenge now advertises a
12 * REST-served metadata URL (Mcp_OAuth::resource_metadata_url), which such
13 * hosts do pass through. But a client that ignores the challenge pointer and
14 * derives the RFC 9728 / RFC 8414 path-insert URLs itself still fetches
15 * /.well-known/oauth-*\/thinkrank/mcp — so this class turns the host's own
16 * behaviour into the fix: if Nginx insists on serving physical files from
17 * /.well-known/, we write the discovery documents AS physical files.
18 *
19 * Publishing is best-effort and deliberately conservative:
20 * - only for a site installed at the domain root (on a subdirectory
21 * install the domain's /.well-known/ belongs to a different tree);
22 * - only invoked from the self-test, and only after it has measured that
23 * the dynamic route is dead (on healthy hosts the rewrites keep serving
24 * and no files are written, so metadata can never go stale there);
25 * - content is refreshed on every publish, so each self-test run keeps the
26 * files current with home_url()/settings changes.
27 *
28 * The documents embed absolute, home_url()-derived identifiers (issuer,
29 * resource, authorization_endpoint, token_endpoint), and the whole point of
30 * writing them as files is that the web server serves them BEFORE WordPress.
31 * So once the site's URL changes, the stale copy wins over the correct dynamic
32 * route and the site advertises its previous domain's issuer — which a
33 * spec-compliant client is required to reject. Nothing used to rewrite them:
34 * publish() ran only from the self-test and remove() only on deactivation, so
35 * the files sat there advertising a domain the site no longer has, with no
36 * signal anywhere (#486). refresh(), hooked to both URL options, is what keeps
37 * them honest.
38 *
39 * Known limitation: the files are extensionless (the URL path has no .json),
40 * so an edge server may send them without an application/json Content-Type.
41 * Every client observed so far parses the body regardless, and a 200 with a
42 * loose Content-Type strictly beats the 404 it replaces.
43 *
44 * @package ThinkRank\Mcp
45 */
46
47 declare(strict_types=1);
48
49 namespace ThinkRank\Mcp;
50
51 if ( ! defined( 'ABSPATH' ) ) {
52 exit; // Exit if accessed directly.
53 }
54
55 /**
56 * Writes/removes physical /.well-known/ OAuth discovery documents.
57 */
58 final class Mcp_Static_Discovery {
59
60 /**
61 * The path-insert discovery files, relative to the site root.
62 *
63 * Only the path-suffixed forms: the bare root forms would need
64 * `oauth-protected-resource` to be a file AND a directory at once, and
65 * spec-compliant clients derive the suffixed form from our path-based
66 * issuer anyway.
67 *
68 * @return array<string,array<string,mixed>> relative path => document.
69 */
70 private static function files(): array {
71 $suffix = Mcp_Pairing::SITE_ENDPOINT_PATH; // thinkrank/mcp.
72 return [
73 '.well-known/oauth-protected-resource/' . $suffix => Mcp_OAuth::protected_resource_metadata(),
74 '.well-known/oauth-authorization-server/' . $suffix => Mcp_OAuth::authorization_server_metadata(),
75 ];
76 }
77
78 /**
79 * Hook the site-URL options so published files cannot outlive the URL they
80 * were generated for.
81 *
82 * Registered unconditionally, not behind `enable_mcp`: a stale document is
83 * harmful whether or not the MCP server is currently switched on, and
84 * these files are served without WordPress having any say in it.
85 *
86 * @since 2.1.0
87 *
88 * @return void
89 */
90 public static function init(): void {
91 // accepted_args = 0 on purpose. update_option_{$option} fires with
92 // ( $old_value, $value, $option ), and refresh()'s only parameter is a
93 // base DIRECTORY — so the default of 1 would hand it the previous site
94 // URL as a filesystem path and it would silently find nothing to do.
95 add_action( 'update_option_home', [ __CLASS__, 'refresh' ], 10, 0 );
96 add_action( 'update_option_siteurl', [ __CLASS__, 'refresh' ], 10, 0 );
97 }
98
99 /**
100 * Bring published files back in line with the current site URL.
101 *
102 * Deliberately does NOT publish where nothing was published before —
103 * writing these files is the self-test's call, made only after it has
104 * measured that the dynamic route is dead. This just keeps an existing
105 * set honest.
106 *
107 * If a rewrite is not possible (permissions changed with the move, which
108 * is common), the files are removed instead. No document beats a document
109 * naming the wrong issuer: without the files the dynamic route serves
110 * again, and the next self-test run republishes if it is still needed.
111 *
112 * @since 2.1.0
113 *
114 * @param string|null $base Base directory (defaults to ABSPATH); a
115 * parameter so tests can point it at a sandbox.
116 * @return void
117 */
118 public static function refresh( ?string $base = null ): void {
119 if ( ! self::published( $base ) ) {
120 return;
121 }
122
123 if ( ! self::publish( $base ) ) {
124 self::remove( $base );
125 }
126 }
127
128 /**
129 * Whether any of the discovery documents exist on disk.
130 *
131 * @since 2.1.0
132 *
133 * @param string|null $base Base directory (defaults to ABSPATH).
134 * @return bool
135 */
136 public static function published( ?string $base = null ): bool {
137 $base = trailingslashit( $base ?? ABSPATH );
138
139 foreach ( array_keys( self::files() ) as $relative ) {
140 if ( is_file( $base . $relative ) ) {
141 return true;
142 }
143 }
144
145 return false;
146 }
147
148 /**
149 * The identifier a published file advertises, when it disagrees with what
150 * this site is now.
151 *
152 * Read off disk rather than over HTTP on purpose. The HTTP probe only sees
153 * these files on a host that actually serves /.well-known/ ahead of
154 * WordPress, and loopback does not always take the same path an external
155 * client does — so a site can serve a stale document to the whole internet
156 * while the self-test's own request never sees it.
157 *
158 * @since 2.1.0
159 *
160 * @param string|null $base Base directory (defaults to ABSPATH).
161 * @return array{file:string,key:string,found:string,expected:string}|null
162 */
163 public static function stale_document( ?string $base = null ): ?array {
164 $base = trailingslashit( $base ?? ABSPATH );
165
166 $identifiers = [
167 '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH => [
168 'key' => 'resource',
169 'expected' => Mcp_Pairing::site_endpoint(),
170 ],
171 '.well-known/oauth-authorization-server/' . Mcp_Pairing::SITE_ENDPOINT_PATH => [
172 'key' => 'issuer',
173 'expected' => Mcp_OAuth::issuer(),
174 ],
175 ];
176
177 foreach ( $identifiers as $relative => $spec ) {
178 $path = $base . $relative;
179
180 if ( ! is_file( $path ) ) {
181 continue;
182 }
183
184 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file we wrote ourselves.
185 $document = json_decode( (string) file_get_contents( $path ), true );
186 $found = is_array( $document ) && isset( $document[ $spec['key'] ] )
187 ? (string) $document[ $spec['key'] ]
188 : '';
189
190 if ( $found !== $spec['expected'] ) {
191 return [
192 'file' => $relative,
193 'key' => $spec['key'],
194 'found' => $found,
195 'expected' => $spec['expected'],
196 ];
197 }
198 }
199
200 return null;
201 }
202
203 /**
204 * Whether static publishing is even applicable here.
205 *
206 * @return bool
207 */
208 public static function applicable(): bool {
209 // Subdirectory install: the domain root (where /.well-known/ lives)
210 // is not ours to write into.
211 return '/' === ( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ?? '/' );
212 }
213
214 /**
215 * Write the discovery documents as physical files. Returns true only when
216 * every file exists with current content afterwards.
217 *
218 * @param string|null $base Base directory (defaults to ABSPATH); a
219 * parameter so tests can point it at a sandbox.
220 * @return bool
221 */
222 public static function publish( ?string $base = null ): bool {
223 if ( null === $base && ! self::applicable() ) {
224 return false;
225 }
226 $base = trailingslashit( $base ?? ABSPATH );
227
228 $all_current = true;
229 foreach ( self::files() as $relative => $document ) {
230 $path = $base . $relative;
231 $json = (string) wp_json_encode( $document );
232
233 // Already current — don't touch the filesystem.
234 if ( is_file( $path ) && (string) file_get_contents( $path ) === $json ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file freshness check.
235 continue;
236 }
237
238 if ( ! wp_mkdir_p( dirname( $path ) ) ) {
239 $all_current = false;
240 continue;
241 }
242 // Silenced deliberately: failing to write here is an ANTICIPATED
243 // outcome, handled by the return value — the site root is often
244 // not writable, and after a migration the files can be owned by
245 // someone else (#486). A raw PHP warning would be emitted into
246 // whatever response happens to be open, which for the self-test
247 // means corrupting its JSON body.
248 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- small static file at a fixed path; WP_Filesystem adds credential prompts this non-interactive path cannot answer.
249 if ( false === @file_put_contents( $path, $json ) ) {
250 $all_current = false;
251 }
252 }
253
254 return $all_current;
255 }
256
257 /**
258 * Remove the published files (and their directories when empty). Called
259 * on plugin deactivation so a static copy cannot keep advertising an
260 * OAuth server that is no longer running.
261 *
262 * @param string|null $base Base directory (defaults to ABSPATH).
263 * @return void
264 */
265 public static function remove( ?string $base = null ): void {
266 $base = trailingslashit( $base ?? ABSPATH );
267
268 foreach ( array_keys( self::files() ) as $relative ) {
269 $path = $base . $relative;
270 if ( is_file( $path ) ) {
271 wp_delete_file( $path );
272 }
273 // Prune now-empty directories up to .well-known itself, but never
274 // .well-known — other software (ACME, Apple Pay) shares it.
275 $dir = dirname( $path );
276 $stop = untrailingslashit( $base . '.well-known' );
277 while ( $dir !== $stop && is_dir( $dir ) && self::dir_is_empty( $dir ) ) {
278 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removing only directories this class created, verified empty.
279 rmdir( $dir );
280 $dir = dirname( $dir );
281 }
282 }
283 }
284
285 /**
286 * Whether a directory contains nothing.
287 *
288 * @param string $dir Directory path.
289 * @return bool
290 */
291 private static function dir_is_empty( string $dir ): bool {
292 $entries = scandir( $dir );
293 return is_array( $entries ) && count( $entries ) <= 2; // Only . and ..
294 }
295 }
296