| @@ -63,8 +63,14 @@ | ||
| 63 | 63 | */ |
| 64 | 64 | private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown'; |
| 65 | 65 | |
| 66 | 66 | /** |
| 67 | + * Query var carrying the resource path a root-form discovery request asked | |
| 68 | + * about, so the handler can tell whether that resource is ours (#516). | |
| 69 | + */ | |
| 70 | + private const WELLKNOWN_RESOURCE_QUERY_VAR = 'thinkrank_mcp_wellknown_resource'; | |
| 71 | + | |
| 72 | + /** | |
| 67 | 73 | * Query var flagging the browser-facing OAuth authorize page. This is |
| 68 | 74 | * served OUTSIDE the REST API on purpose: a REST route only honors cookie |
| 69 | 75 | * auth when a REST nonce accompanies it, but a browser arriving from |
| 70 | 76 | * wp-login carries the cookie with NO nonce — so is_user_logged_in() |
| @@ -176,11 +182,20 @@ | ||
| 176 | 182 | ); |
| 177 | 183 | // Root-form fallback for clients that only try the bare well-known |
| 178 | 184 | // URL. Harmless when another plugin also registers this exact regex — |
| 179 | 185 | // last registrant wins, and our clients use the path-suffixed form. |
| 186 | + // | |
| 187 | + // The trailing path is CAPTURED rather than discarded (#516). It names | |
| 188 | + // the resource the client is asking about, and answering for a resource | |
| 189 | + // that is not ours is how this rule broke subdirectory multisite: the | |
| 190 | + // network root belongs to the main site, so a client discovering | |
| 191 | + // /ca/thinkrank/mcp was served the MAIN site's document, with every | |
| 192 | + // endpoint missing the /ca/ prefix. The same rule also answered for | |
| 193 | + // another plugin's resource path on a plain single site. The handler | |
| 194 | + // below compares the capture with our own path and declines the rest. | |
| 180 | 195 | add_rewrite_rule( |
| 181 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 182 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 196 | + '^\.well-known/oauth-(protected-resource|authorization-server)(/.*)?/?$', | |
| 197 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]', | |
| 183 | 198 | 'top' |
| 184 | 199 | ); |
| 185 | 200 | // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the |
| 186 | 201 | // path-INSERT form above, but the older OpenID Connect Discovery |
| @@ -209,8 +224,13 @@ | ||
| 209 | 224 | $expected = [ |
| 210 | 225 | '^thinkrank/mcp/([a-f0-9]{64})/?$', |
| 211 | 226 | '^thinkrank/mcp/?$', |
| 212 | 227 | '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', |
| 228 | + // Listed so an upgrade re-flushes and the pre-#516 rule, which | |
| 229 | + // discarded the resource path, leaves the stored rewrite table. | |
| 230 | + // Without this the old regex keeps matching until someone re-saves | |
| 231 | + // permalinks by hand. | |
| 232 | + '^\.well-known/oauth-(protected-resource|authorization-server)(/.*)?/?$', | |
| 213 | 233 | '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', |
| 214 | 234 | '^thinkrank/mcp/\.well-known/openid-configuration/?$', |
| 215 | 235 | '^thinkrank/authorize/?$', |
| 216 | 236 | ]; |
| @@ -234,13 +254,174 @@ | ||
| 234 | 254 | public function register_query_var( array $vars ): array { |
| 235 | 255 | $vars[] = self::QUERY_VAR; |
| 236 | 256 | $vars[] = self::TOKEN_QUERY_VAR; |
| 237 | 257 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 258 | + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR; | |
| 238 | 259 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 239 | 260 | return $vars; |
| 240 | 261 | } |
| 241 | 262 | |
| 242 | 263 | /** |
| 264 | + * Resolve a root-form discovery request to the site that owns the resource. | |
| 265 | + * | |
| 266 | + * The path-suffixed and issuer-suffixed rules are already pinned to | |
| 267 | + * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming | |
| 268 | + * something else. Three outcomes: | |
| 269 | + * | |
| 270 | + * - `0` — serve from the current site. That covers the bare form | |
| 271 | + * (`/.well-known/oauth-authorization-server`, the whole reason the | |
| 272 | + * fallback rule exists) and this site's own endpoint path. | |
| 273 | + * - a blog id — a subdirectory multisite request for another site's | |
| 274 | + * resource. On subdirectory multisite everything under the network root | |
| 275 | + * is served by the MAIN site, so a client discovering | |
| 276 | + * `/ca/thinkrank/mcp` lands here; the document has to be built from the | |
| 277 | + * `/ca/` site or every endpoint in it loses the prefix. | |
| 278 | + * - `null` — not ours. Another plugin's resource, an unknown site path, or | |
| 279 | + * a path that merely contains ours. | |
| 280 | + * | |
| 281 | + * @since 2.9.0 | |
| 282 | + * | |
| 283 | + * @param \WP $wp The WP request object. | |
| 284 | + * @return int|null Blog id to serve from, 0 for the current site, null to decline. | |
| 285 | + */ | |
| 286 | + private static function resolve_wellknown_target( $wp ): ?int { | |
| 287 | + $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] ) | |
| 288 | + ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' ) | |
| 289 | + : ''; | |
| 290 | + | |
| 291 | + $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ); | |
| 292 | + | |
| 293 | + if ( '' === $requested || $requested === $ours ) { | |
| 294 | + return 0; | |
| 295 | + } | |
| 296 | + | |
| 297 | + if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) { | |
| 298 | + return null; | |
| 299 | + } | |
| 300 | + | |
| 301 | + // Whatever precedes our endpoint path is the candidate site path: | |
| 302 | + // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path | |
| 303 | + // segment, not a substring, so `thinkrank/mcp-other` cannot qualify. | |
| 304 | + $candidate = '/' . $requested; | |
| 305 | + $suffix = '/' . $ours; | |
| 306 | + | |
| 307 | + if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) { | |
| 308 | + return null; | |
| 309 | + } | |
| 310 | + | |
| 311 | + $site_path = substr( $candidate, 0, - strlen( $ours ) ); | |
| 312 | + $domain = self::request_domain(); | |
| 313 | + | |
| 314 | + if ( '' === $domain || '' === $site_path ) { | |
| 315 | + return null; | |
| 316 | + } | |
| 317 | + | |
| 318 | + $site = get_site_by_path( $domain, $site_path ); | |
| 319 | + | |
| 320 | + if ( ! $site ) { | |
| 321 | + return null; | |
| 322 | + } | |
| 323 | + | |
| 324 | + // get_site_by_path() walks the path segments and falls back to the | |
| 325 | + // network's root site when none match, so an unknown prefix comes back | |
| 326 | + // as the MAIN site rather than as nothing. Taking that at face value | |
| 327 | + // reinstates the exact bug for every path that is not a real subsite: | |
| 328 | + // /nope/thinkrank/mcp would be answered with the main site's document. | |
| 329 | + // Require the match to be the path that was actually asked for. | |
| 330 | + if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) { | |
| 331 | + return null; | |
| 332 | + } | |
| 333 | + | |
| 334 | + // get_sites() applies no status filter, so a site the network has taken | |
| 335 | + // out of service resolves like any other. Advertising an authorization | |
| 336 | + // server for one would point a client at an endpoint that cannot serve | |
| 337 | + // it. `public` is deliberately NOT checked: on multisite that flag is | |
| 338 | + // search-engine visibility, not availability, and a site can reasonably | |
| 339 | + // be hidden from search while still running MCP. | |
| 340 | + if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) { | |
| 341 | + return null; | |
| 342 | + } | |
| 343 | + | |
| 344 | + return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id; | |
| 345 | + } | |
| 346 | + | |
| 347 | + /** | |
| 348 | + * Host for a `get_site_by_path()` lookup. | |
| 349 | + * | |
| 350 | + * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the | |
| 351 | + * default ports when it resolves the current site, so a development network | |
| 352 | + * running on a non-default port keeps it, and stripping every port here | |
| 353 | + * would fail to match those rows. | |
| 354 | + * | |
| 355 | + * @since 2.9.0 | |
| 356 | + * | |
| 357 | + * @return string Host, or an empty string when the request carries none. | |
| 358 | + */ | |
| 359 | + private static function request_domain(): string { | |
| 360 | + if ( empty( $_SERVER['HTTP_HOST'] ) ) { | |
| 361 | + return ''; | |
| 362 | + } | |
| 363 | + | |
| 364 | + $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ); | |
| 365 | + | |
| 366 | + if ( ':80' === substr( $host, -3 ) ) { | |
| 367 | + return substr( $host, 0, -3 ); | |
| 368 | + } | |
| 369 | + | |
| 370 | + if ( ':443' === substr( $host, -4 ) ) { | |
| 371 | + return substr( $host, 0, -4 ); | |
| 372 | + } | |
| 373 | + | |
| 374 | + return $host; | |
| 375 | + } | |
| 376 | + | |
| 377 | + /** | |
| 378 | + * Build a discovery document from the site that owns the resource. | |
| 379 | + * | |
| 380 | + * The switch is what makes the returned endpoints carry the subsite prefix, | |
| 381 | + * since every URL in the document comes from `home_url()` / `rest_url()`. | |
| 382 | + * Settings memoizes per setting name with no notion of which site it read | |
| 383 | + * from; it clears itself on `switch_blog` (see Settings::init), which is | |
| 384 | + * what stops the MCP-enabled check below answering for the previous site. | |
| 385 | + * | |
| 386 | + * Returns null when the owning site has MCP turned off: a site that is not | |
| 387 | + * serving MCP must not advertise an authorization server for it. | |
| 388 | + * | |
| 389 | + * @since 2.9.0 | |
| 390 | + * | |
| 391 | + * @param int $blog_id Blog to build from, 0 for the current site. | |
| 392 | + * @param string $doc Document type from the rewrite. | |
| 393 | + * @return array<string,mixed>|null | |
| 394 | + */ | |
| 395 | + private static function discovery_document_for( int $blog_id, string $doc ): ?array { | |
| 396 | + $switched = false; | |
| 397 | + | |
| 398 | + if ( $blog_id > 0 ) { | |
| 399 | + switch_to_blog( $blog_id ); | |
| 400 | + $switched = true; | |
| 401 | + } | |
| 402 | + | |
| 403 | + $data = null; | |
| 404 | + | |
| 405 | + try { | |
| 406 | + if ( self::is_enabled() ) { | |
| 407 | + $data = 'authorization-server' === $doc | |
| 408 | + ? Mcp_OAuth::authorization_server_metadata() | |
| 409 | + : Mcp_OAuth::protected_resource_metadata(); | |
| 410 | + } | |
| 411 | + } finally { | |
| 412 | + // A throw between the switch and the restore would leave the rest | |
| 413 | + // of the request, including shutdown hooks, running against the | |
| 414 | + // wrong site. Cheap to make impossible. | |
| 415 | + if ( $switched ) { | |
| 416 | + restore_current_blog(); | |
| 417 | + } | |
| 418 | + } | |
| 419 | + | |
| 420 | + return $data; | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** | |
| 243 | 424 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so it |
| 244 | 425 | * fires before the main query, and short-circuits WP entirely. |
| 245 | 426 | * |
| 246 | 427 | * @param \WP $wp The WP request object. |
| @@ -248,16 +429,30 @@ | ||
| 248 | 429 | */ |
| 249 | 430 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 250 | 431 | // OAuth discovery documents (served at the site root). |
| 251 | 432 | if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { |
| 252 | - if ( ! self::is_enabled() ) { | |
| 433 | + // The path after the document type names the RESOURCE being | |
| 434 | + // discovered, and it used to be discarded (#516). Resolve it to | |
| 435 | + // the site that actually owns it, which on subdirectory multisite | |
| 436 | + // is how /ca/thinkrank/mcp stops being answered by the main site | |
| 437 | + // with endpoints that have no /ca/ in them. | |
| 438 | + $target = self::resolve_wellknown_target( $wp ); | |
| 439 | + | |
| 440 | + if ( null === $target ) { | |
| 253 | 441 | status_header( 404 ); |
| 254 | 442 | exit; |
| 255 | 443 | } |
| 256 | - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; | |
| 257 | - $data = 'authorization-server' === $doc | |
| 258 | - ? Mcp_OAuth::authorization_server_metadata() | |
| 259 | - : Mcp_OAuth::protected_resource_metadata(); | |
| 444 | + | |
| 445 | + $data = self::discovery_document_for( | |
| 446 | + $target, | |
| 447 | + (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] | |
| 448 | + ); | |
| 449 | + | |
| 450 | + if ( null === $data ) { | |
| 451 | + status_header( 404 ); | |
| 452 | + exit; | |
| 453 | + } | |
| 454 | + | |
| 260 | 455 | status_header( 200 ); |
| 261 | 456 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 262 | 457 | // Discovery metadata is public + cacheable. |
| 263 | 458 | header( 'Cache-Control: public, max-age=3600' ); |