| @@ -53,8 +53,34 @@ | ||
| 53 | 53 | */ |
| 54 | 54 | private const QUERY_VAR = 'thinkrank_mcp'; |
| 55 | 55 | |
| 56 | 56 | /** |
| 57 | + * Whether this request is an MCP call being served. | |
| 58 | + * | |
| 59 | + * The pretty /thinkrank/mcp route is dispatched from `parse_request`, so it | |
| 60 | + * is neither `is_admin()` nor `REST_REQUEST`. Anything deciding whether a | |
| 61 | + * request may take on deferred work needs to be able to see it (#764). | |
| 62 | + * | |
| 63 | + * @since 2.10.0 | |
| 64 | + * @var bool | |
| 65 | + */ | |
| 66 | + private static $serving_request = false; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Whether an MCP call is being served on this request. | |
| 70 | + * | |
| 71 | + * Pair with `is_user_logged_in()` to mean "an authenticated MCP call": | |
| 72 | + * Mcp_Server sets the current user only once a credential has validated, so | |
| 73 | + * an unauthenticated POST never looks like one. | |
| 74 | + * | |
| 75 | + * @since 2.10.0 | |
| 76 | + * @return bool | |
| 77 | + */ | |
| 78 | + public static function is_serving_request(): bool { | |
| 79 | + return self::$serving_request; | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 57 | 83 | * Query var carrying the token when embedded in the URL path. |
| 58 | 84 | */ |
| 59 | 85 | private const TOKEN_QUERY_VAR = 'thinkrank_mcp_token'; |
| 60 | 86 | |
| @@ -63,8 +89,14 @@ | ||
| 63 | 89 | */ |
| 64 | 90 | private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown'; |
| 65 | 91 | |
| 66 | 92 | /** |
| 93 | + * Query var carrying the resource path a root-form discovery request asked | |
| 94 | + * about, so the handler can tell whether that resource is ours (#516). | |
| 95 | + */ | |
| 96 | + private const WELLKNOWN_RESOURCE_QUERY_VAR = 'thinkrank_mcp_wellknown_resource'; | |
| 97 | + | |
| 98 | + /** | |
| 67 | 99 | * Query var flagging the browser-facing OAuth authorize page. This is |
| 68 | 100 | * served OUTSIDE the REST API on purpose: a REST route only honors cookie |
| 69 | 101 | * auth when a REST nonce accompanies it, but a browser arriving from |
| 70 | 102 | * wp-login carries the cookie with NO nonce — so is_user_logged_in() |
| @@ -81,8 +113,14 @@ | ||
| 81 | 113 | */ |
| 82 | 114 | public function init(): void { |
| 83 | 115 | add_action( 'rest_api_init', [ $this, 'register_rest' ] ); |
| 84 | 116 | |
| 117 | + // Published /.well-known/ files are served ahead of WordPress, so a | |
| 118 | + // site URL change leaves them advertising the old domain's issuer with | |
| 119 | + // nothing to correct them. Registered unconditionally: a stale | |
| 120 | + // document is harmful whether or not MCP is currently enabled (#486). | |
| 121 | + Mcp_Static_Discovery::init(); | |
| 122 | + | |
| 85 | 123 | // Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler. |
| 86 | 124 | add_action( 'init', [ $this, 'add_rewrite' ] ); |
| 87 | 125 | add_filter( 'query_vars', [ $this, 'register_query_var' ] ); |
| 88 | 126 | add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] ); |
| @@ -140,87 +178,197 @@ | ||
| 140 | 178 | * |
| 141 | 179 | * @return void |
| 142 | 180 | */ |
| 143 | 181 | public function add_rewrite(): void { |
| 144 | - // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user | |
| 145 | - // pastes into their AI client (no separate token field). The bare | |
| 146 | - // /thinkrank/mcp still works with a Bearer token. | |
| 147 | - add_rewrite_rule( | |
| 148 | - '^thinkrank/mcp/([a-f0-9]{64})/?$', | |
| 149 | - 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', | |
| 150 | - 'top' | |
| 151 | - ); | |
| 152 | - add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); | |
| 182 | + $rules = self::rewrite_rules(); | |
| 153 | 183 | |
| 154 | - // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the | |
| 155 | - // `.well-known` segment BEFORE the resource path, so our resource at | |
| 156 | - // /thinkrank/mcp is discovered at the path-suffixed form: | |
| 157 | - // /.well-known/oauth-protected-resource/thinkrank/mcp | |
| 158 | - // /.well-known/oauth-authorization-server/thinkrank/mcp | |
| 159 | - // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp') | |
| 160 | - // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly | |
| 161 | - // these URLs — and the rule stays specific to OUR path. That matters | |
| 162 | - // for coexistence: another plugin serving its own MCP OAuth surface | |
| 163 | - // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite | |
| 164 | - // rules are keyed by regex, so a shared broad rule would be silently | |
| 165 | - // overwritten by whichever plugin registers last. | |
| 166 | - add_rewrite_rule( | |
| 167 | - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', | |
| 168 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 169 | - 'top' | |
| 170 | - ); | |
| 171 | - // Root-form fallback for clients that only try the bare well-known | |
| 172 | - // URL. Harmless when another plugin also registers this exact regex — | |
| 173 | - // last registrant wins, and our clients use the path-suffixed form. | |
| 174 | - add_rewrite_rule( | |
| 175 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 176 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 177 | - 'top' | |
| 178 | - ); | |
| 179 | - // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the | |
| 180 | - // path-INSERT form above, but the older OpenID Connect Discovery | |
| 181 | - // convention appends instead, and clients built on an OIDC library | |
| 182 | - // try that shape first (sometimes only that shape). Serving both | |
| 183 | - // costs two rules and removes a whole class of "server does not | |
| 184 | - // implement OAuth" failures from clients that never fall back. | |
| 185 | - add_rewrite_rule( | |
| 186 | - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', | |
| 187 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 188 | - 'top' | |
| 189 | - ); | |
| 190 | - add_rewrite_rule( | |
| 191 | - '^thinkrank/mcp/\.well-known/openid-configuration/?$', | |
| 192 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', | |
| 193 | - 'top' | |
| 194 | - ); | |
| 184 | + foreach ( $rules as $regex => $query ) { | |
| 185 | + add_rewrite_rule( $regex, $query, 'top' ); | |
| 186 | + } | |
| 195 | 187 | |
| 196 | - // Browser-facing OAuth consent page — served OUTSIDE REST so cookie | |
| 197 | - // auth (is_user_logged_in) works after the wp-login round-trip. | |
| 198 | - add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); | |
| 199 | - | |
| 200 | 188 | // Self-heal: flush once if ANY of our rules is missing from the stored |
| 201 | 189 | // rewrite table, so the endpoints work without a manual permalink |
| 202 | 190 | // re-save (and newly added rules trigger a re-flush on upgrade). |
| 203 | - $expected = [ | |
| 204 | - '^thinkrank/mcp/([a-f0-9]{64})/?$', | |
| 205 | - '^thinkrank/mcp/?$', | |
| 206 | - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', | |
| 207 | - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', | |
| 208 | - '^thinkrank/mcp/\.well-known/openid-configuration/?$', | |
| 209 | - '^thinkrank/authorize/?$', | |
| 191 | + // | |
| 192 | + // Checked against the same array that was just registered, never | |
| 193 | + // against a second hand-maintained list. The two drifted apart once | |
| 194 | + // already: #775's first pass changed the discovery regex and left the | |
| 195 | + // superseded one in the list, so a rule that is never registered was | |
| 196 | + // permanently "missing" and every front-end request rebuilt the whole | |
| 197 | + // rewrite table. Measured at 4 regenerations across 3 page loads | |
| 198 | + // against 0 before the change — silent, because a rebuilt table is | |
| 199 | + // still a correct one. | |
| 200 | + // | |
| 201 | + // It also has to notice the opposite: a rule of ours that is stored | |
| 202 | + // but no longer wanted. The bare discovery rule is registered only | |
| 203 | + // while thinkrank_mcp_serve_root_discovery is on, and "missing" alone | |
| 204 | + // never fires when it is switched off again, because every rule still | |
| 205 | + // registered is present. The stale rule then kept claiming the bare | |
| 206 | + // URL from other MCP plugins (#775) until someone re-saved | |
| 207 | + // permalinks. The unwanted set is derived from the same table built | |
| 208 | + // with every optional rule on, not listed, so it cannot drift either. | |
| 209 | + // A flush rebuilds the table from what this request registered, so | |
| 210 | + // the stale rule is gone afterwards and the check settles instead of | |
| 211 | + // flushing on every request. | |
| 212 | + $stored = get_option( 'rewrite_rules' ); | |
| 213 | + if ( is_array( $stored ) ) { | |
| 214 | + $unwanted = array_diff_key( self::rewrite_rules( true ), $rules ); | |
| 215 | + $stale = array_intersect_key( $unwanted, $stored ); | |
| 216 | + $missing = array_diff_key( $rules, $stored ); | |
| 217 | + | |
| 218 | + if ( ! empty( $missing ) || ! empty( $stale ) ) { | |
| 219 | + flush_rewrite_rules( false ); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + /** | |
| 225 | + * Every rewrite rule this plugin owns, as regex => query string. | |
| 226 | + * | |
| 227 | + * Single source of truth for registration AND for the self-heal check, so | |
| 228 | + * the two cannot describe different sets of rules. All of them register at | |
| 229 | + * `'top'`; a `'bottom'` rule would never be reached, because core's own | |
| 230 | + * pagename rule matches almost any path ahead of it. | |
| 231 | + * | |
| 232 | + * @since 2.10.0 | |
| 233 | + * | |
| 234 | + * @param bool|null $root_discovery Include the opt-in bare discovery rule. | |
| 235 | + * Null (the default) follows | |
| 236 | + * {@see self::serves_root_discovery()}; | |
| 237 | + * true is how add_rewrite() learns which | |
| 238 | + * optional rules exist, to retire one | |
| 239 | + * that was switched off. | |
| 240 | + * @return array<string,string> Regex => query string. | |
| 241 | + */ | |
| 242 | + private static function rewrite_rules( ?bool $root_discovery = null ): array { | |
| 243 | + $endpoint = preg_quote( trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ), '/' ); | |
| 244 | + | |
| 245 | + $rules = [ | |
| 246 | + // Token-in-URL form: /thinkrank/mcp/<token> — a single string the | |
| 247 | + // user pastes into their AI client (no separate token field). The | |
| 248 | + // bare /thinkrank/mcp still works with a Bearer token. | |
| 249 | + '^thinkrank/mcp/([a-f0-9]{64})/?$' => | |
| 250 | + 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', | |
| 251 | + '^thinkrank/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1', | |
| 252 | + | |
| 253 | + // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place | |
| 254 | + // the `.well-known` segment BEFORE the resource path, so our | |
| 255 | + // resource at /thinkrank/mcp is discovered at: | |
| 256 | + // /.well-known/oauth-protected-resource/thinkrank/mcp | |
| 257 | + // /.well-known/oauth-authorization-server/thinkrank/mcp | |
| 258 | + // The OAuth issuer is the path-based identifier | |
| 259 | + // home_url('/thinkrank/mcp') (see Mcp_OAuth::issuer), so | |
| 260 | + // spec-compliant clients derive exactly these URLs. | |
| 261 | + '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$' => | |
| 262 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 263 | + | |
| 264 | + // The same form with a site prefix in front of our endpoint path, | |
| 265 | + // which is how subdirectory multisite discovers /ca/thinkrank/mcp | |
| 266 | + // (#516). The trailing path is CAPTURED rather than discarded and | |
| 267 | + // the pattern only accepts a path ENDING in our own endpoint: an | |
| 268 | + // earlier version matched `(/.*)?` — anything at all — and so | |
| 269 | + // claimed every neighbouring MCP plugin's RFC 9728 discovery URL | |
| 270 | + // alongside our own (#775). | |
| 271 | + // | |
| 272 | + // Declining inside the handler does not undo that, and this is the | |
| 273 | + // part worth being precise about: rewrite matching happens once, in | |
| 274 | + // WP::parse_request(), before the `parse_request` action our | |
| 275 | + // handler runs on. By then this rule has already won and the owning | |
| 276 | + // plugin's rule has not matched, so its query var is never set and | |
| 277 | + // its handler never fires. Returning instead of exiting would leave | |
| 278 | + // the request to die in the main query — a 404 either way, exactly | |
| 279 | + // as broken for the neighbour as serving them our document was. The | |
| 280 | + // rule itself has to stop matching, so normal rewrite resolution | |
| 281 | + // reaches the plugin that owns the URL. | |
| 282 | + // | |
| 283 | + // The handler still validates what this does match: a prefix that | |
| 284 | + // is not a real site on this network is declined there. | |
| 285 | + '^\.well-known/oauth-(protected-resource|authorization-server)((?:/.*)?/' . $endpoint . ')/?$' => | |
| 286 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]', | |
| 287 | + | |
| 288 | + // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the | |
| 289 | + // path-INSERT form above, but the older OpenID Connect Discovery | |
| 290 | + // convention appends instead, and clients built on an OIDC library | |
| 291 | + // try that shape first (sometimes only that shape). Both of these | |
| 292 | + // live under our own endpoint path, so neither can collide with | |
| 293 | + // another plugin's discovery URLs. | |
| 294 | + '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => | |
| 295 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 296 | + '^thinkrank/mcp/\.well-known/openid-configuration/?$' => | |
| 297 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', | |
| 298 | + | |
| 299 | + // Browser-facing OAuth consent page — served OUTSIDE REST so cookie | |
| 300 | + // auth (is_user_logged_in) works after the wp-login round-trip. | |
| 301 | + '^thinkrank/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', | |
| 210 | 302 | ]; |
| 211 | - $rules = get_option( 'rewrite_rules' ); | |
| 212 | - if ( is_array( $rules ) ) { | |
| 213 | - foreach ( $expected as $rule ) { | |
| 214 | - if ( ! isset( $rules[ $rule ] ) ) { | |
| 215 | - flush_rewrite_rules( false ); | |
| 216 | - break; | |
| 217 | - } | |
| 218 | - } | |
| 303 | + | |
| 304 | + if ( $root_discovery ?? self::serves_root_discovery() ) { | |
| 305 | + $rules['^\.well-known/oauth-(protected-resource|authorization-server)/?$'] = | |
| 306 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]'; | |
| 219 | 307 | } |
| 308 | + | |
| 309 | + return $rules; | |
| 220 | 310 | } |
| 221 | 311 | |
| 222 | 312 | /** |
| 313 | + * Whether to answer the BARE `/.well-known/oauth-*` URLs, with no resource | |
| 314 | + * path after the document name. Off by default. | |
| 315 | + * | |
| 316 | + * That URL is not ours to answer, in two separate senses. | |
| 317 | + * | |
| 318 | + * It is not ours by the specs. RFC 9728 §3.1 builds a resource's metadata | |
| 319 | + * URL by inserting `.well-known` before the resource's path, so the bare | |
| 320 | + * form is the metadata URL for the resource `https://example.com` — the | |
| 321 | + * site root. Ours is `https://example.com/thinkrank/mcp`. §3.3 requires the | |
| 322 | + * `resource` in the document to equal the identifier it was fetched for, so | |
| 323 | + * answering there returns a document that contradicts its own URL. RFC 8414 | |
| 324 | + * §3.3 says the same of `issuer`. A client that validates is entitled to | |
| 325 | + * reject it; the only clients it ever helped were those that do not. | |
| 326 | + * | |
| 327 | + * And it is not ours on a site with a second MCP plugin. Registered at | |
| 328 | + * `'top'`, our rule did not merely enter the ordering race for that URL, it | |
| 329 | + * went to the front of it — so a neighbour's client reading the bare form | |
| 330 | + * got OUR resource identifier while connecting somewhere else, which is the | |
| 331 | + * same RFC 9728 mismatch #775 was filed about, reached by the other URL. | |
| 332 | + * Because the winner depends on registration and flush order, it also | |
| 333 | + * reproduced intermittently: the same site could behave differently after | |
| 334 | + * an unrelated flush. | |
| 335 | + * | |
| 336 | + * Nothing of ours needs it. The 401 challenge advertises | |
| 337 | + * {@see Mcp_OAuth::resource_metadata_url()}, a route in our own REST | |
| 338 | + * namespace that no other plugin can claim; the path-insert and | |
| 339 | + * OIDC-suffix forms above cover spec-compliant and OIDC-library clients; | |
| 340 | + * {@see Mcp_Self_Test} probes none of the bare URLs; and | |
| 341 | + * {@see Mcp_Static_Discovery} publishes only the path-suffixed files. | |
| 342 | + * | |
| 343 | + * What it did cover is a client that derives the metadata URL from the host | |
| 344 | + * alone, dropping the path. On a site where ThinkRank is the only MCP | |
| 345 | + * plugin that client worked, and this filter is how such a site keeps it | |
| 346 | + * working. It is opt-in because switching it on is a claim over a shared | |
| 347 | + * URL, and only the site owner knows whether anything else wants it. | |
| 348 | + * | |
| 349 | + * @since 2.10.0 | |
| 350 | + * | |
| 351 | + * @return bool | |
| 352 | + */ | |
| 353 | + public static function serves_root_discovery(): bool { | |
| 354 | + /** | |
| 355 | + * Filter whether ThinkRank answers the bare `/.well-known/oauth-*` | |
| 356 | + * discovery URLs, which carry no resource path. | |
| 357 | + * | |
| 358 | + * Off by default: the URL identifies the site root rather than our MCP | |
| 359 | + * endpoint, and claiming it breaks any other MCP plugin on the site. | |
| 360 | + * Switch it on only where ThinkRank is the sole MCP provider and a | |
| 361 | + * client derives the metadata URL from the host without the path. | |
| 362 | + * | |
| 363 | + * @since 2.10.0 | |
| 364 | + * | |
| 365 | + * @param bool $serve Whether to register the bare discovery rules. | |
| 366 | + */ | |
| 367 | + return (bool) apply_filters( 'thinkrank_mcp_serve_root_discovery', false ); | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** | |
| 223 | 371 | * Register our query vars. |
| 224 | 372 | * |
| 225 | 373 | * @param string[] $vars Registered query vars. |
| 226 | 374 | * @return string[] |
| @@ -228,13 +376,174 @@ | ||
| 228 | 376 | public function register_query_var( array $vars ): array { |
| 229 | 377 | $vars[] = self::QUERY_VAR; |
| 230 | 378 | $vars[] = self::TOKEN_QUERY_VAR; |
| 231 | 379 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 380 | + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR; | |
| 232 | 381 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 233 | 382 | return $vars; |
| 234 | 383 | } |
| 235 | 384 | |
| 236 | 385 | /** |
| 386 | + * Resolve a root-form discovery request to the site that owns the resource. | |
| 387 | + * | |
| 388 | + * The path-suffixed and issuer-suffixed rules are already pinned to | |
| 389 | + * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming | |
| 390 | + * something else. Three outcomes: | |
| 391 | + * | |
| 392 | + * - `0` — serve from the current site. That covers the bare form | |
| 393 | + * (`/.well-known/oauth-authorization-server`, the whole reason the | |
| 394 | + * fallback rule exists) and this site's own endpoint path. | |
| 395 | + * - a blog id — a subdirectory multisite request for another site's | |
| 396 | + * resource. On subdirectory multisite everything under the network root | |
| 397 | + * is served by the MAIN site, so a client discovering | |
| 398 | + * `/ca/thinkrank/mcp` lands here; the document has to be built from the | |
| 399 | + * `/ca/` site or every endpoint in it loses the prefix. | |
| 400 | + * - `null` — not ours. Another plugin's resource, an unknown site path, or | |
| 401 | + * a path that merely contains ours. | |
| 402 | + * | |
| 403 | + * @since 2.9.0 | |
| 404 | + * | |
| 405 | + * @param \WP $wp The WP request object. | |
| 406 | + * @return int|null Blog id to serve from, 0 for the current site, null to decline. | |
| 407 | + */ | |
| 408 | + private static function resolve_wellknown_target( $wp ): ?int { | |
| 409 | + $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] ) | |
| 410 | + ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' ) | |
| 411 | + : ''; | |
| 412 | + | |
| 413 | + $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ); | |
| 414 | + | |
| 415 | + if ( '' === $requested || $requested === $ours ) { | |
| 416 | + return 0; | |
| 417 | + } | |
| 418 | + | |
| 419 | + if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) { | |
| 420 | + return null; | |
| 421 | + } | |
| 422 | + | |
| 423 | + // Whatever precedes our endpoint path is the candidate site path: | |
| 424 | + // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path | |
| 425 | + // segment, not a substring, so `thinkrank/mcp-other` cannot qualify. | |
| 426 | + $candidate = '/' . $requested; | |
| 427 | + $suffix = '/' . $ours; | |
| 428 | + | |
| 429 | + if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) { | |
| 430 | + return null; | |
| 431 | + } | |
| 432 | + | |
| 433 | + $site_path = substr( $candidate, 0, - strlen( $ours ) ); | |
| 434 | + $domain = self::request_domain(); | |
| 435 | + | |
| 436 | + if ( '' === $domain || '' === $site_path ) { | |
| 437 | + return null; | |
| 438 | + } | |
| 439 | + | |
| 440 | + $site = get_site_by_path( $domain, $site_path ); | |
| 441 | + | |
| 442 | + if ( ! $site ) { | |
| 443 | + return null; | |
| 444 | + } | |
| 445 | + | |
| 446 | + // get_site_by_path() walks the path segments and falls back to the | |
| 447 | + // network's root site when none match, so an unknown prefix comes back | |
| 448 | + // as the MAIN site rather than as nothing. Taking that at face value | |
| 449 | + // reinstates the exact bug for every path that is not a real subsite: | |
| 450 | + // /nope/thinkrank/mcp would be answered with the main site's document. | |
| 451 | + // Require the match to be the path that was actually asked for. | |
| 452 | + if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) { | |
| 453 | + return null; | |
| 454 | + } | |
| 455 | + | |
| 456 | + // get_sites() applies no status filter, so a site the network has taken | |
| 457 | + // out of service resolves like any other. Advertising an authorization | |
| 458 | + // server for one would point a client at an endpoint that cannot serve | |
| 459 | + // it. `public` is deliberately NOT checked: on multisite that flag is | |
| 460 | + // search-engine visibility, not availability, and a site can reasonably | |
| 461 | + // be hidden from search while still running MCP. | |
| 462 | + if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) { | |
| 463 | + return null; | |
| 464 | + } | |
| 465 | + | |
| 466 | + return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id; | |
| 467 | + } | |
| 468 | + | |
| 469 | + /** | |
| 470 | + * Host for a `get_site_by_path()` lookup. | |
| 471 | + * | |
| 472 | + * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the | |
| 473 | + * default ports when it resolves the current site, so a development network | |
| 474 | + * running on a non-default port keeps it, and stripping every port here | |
| 475 | + * would fail to match those rows. | |
| 476 | + * | |
| 477 | + * @since 2.9.0 | |
| 478 | + * | |
| 479 | + * @return string Host, or an empty string when the request carries none. | |
| 480 | + */ | |
| 481 | + private static function request_domain(): string { | |
| 482 | + if ( empty( $_SERVER['HTTP_HOST'] ) ) { | |
| 483 | + return ''; | |
| 484 | + } | |
| 485 | + | |
| 486 | + $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ); | |
| 487 | + | |
| 488 | + if ( ':80' === substr( $host, -3 ) ) { | |
| 489 | + return substr( $host, 0, -3 ); | |
| 490 | + } | |
| 491 | + | |
| 492 | + if ( ':443' === substr( $host, -4 ) ) { | |
| 493 | + return substr( $host, 0, -4 ); | |
| 494 | + } | |
| 495 | + | |
| 496 | + return $host; | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** | |
| 500 | + * Build a discovery document from the site that owns the resource. | |
| 501 | + * | |
| 502 | + * The switch is what makes the returned endpoints carry the subsite prefix, | |
| 503 | + * since every URL in the document comes from `home_url()` / `rest_url()`. | |
| 504 | + * Settings memoizes per setting name with no notion of which site it read | |
| 505 | + * from; it clears itself on `switch_blog` (see Settings::init), which is | |
| 506 | + * what stops the MCP-enabled check below answering for the previous site. | |
| 507 | + * | |
| 508 | + * Returns null when the owning site has MCP turned off: a site that is not | |
| 509 | + * serving MCP must not advertise an authorization server for it. | |
| 510 | + * | |
| 511 | + * @since 2.9.0 | |
| 512 | + * | |
| 513 | + * @param int $blog_id Blog to build from, 0 for the current site. | |
| 514 | + * @param string $doc Document type from the rewrite. | |
| 515 | + * @return array<string,mixed>|null | |
| 516 | + */ | |
| 517 | + private static function discovery_document_for( int $blog_id, string $doc ): ?array { | |
| 518 | + $switched = false; | |
| 519 | + | |
| 520 | + if ( $blog_id > 0 ) { | |
| 521 | + switch_to_blog( $blog_id ); | |
| 522 | + $switched = true; | |
| 523 | + } | |
| 524 | + | |
| 525 | + $data = null; | |
| 526 | + | |
| 527 | + try { | |
| 528 | + if ( self::is_enabled() ) { | |
| 529 | + $data = 'authorization-server' === $doc | |
| 530 | + ? Mcp_OAuth::authorization_server_metadata() | |
| 531 | + : Mcp_OAuth::protected_resource_metadata(); | |
| 532 | + } | |
| 533 | + } finally { | |
| 534 | + // A throw between the switch and the restore would leave the rest | |
| 535 | + // of the request, including shutdown hooks, running against the | |
| 536 | + // wrong site. Cheap to make impossible. | |
| 537 | + if ( $switched ) { | |
| 538 | + restore_current_blog(); | |
| 539 | + } | |
| 540 | + } | |
| 541 | + | |
| 542 | + return $data; | |
| 543 | + } | |
| 544 | + | |
| 545 | + /** | |
| 237 | 546 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so it |
| 238 | 547 | * fires before the main query, and short-circuits WP entirely. |
| 239 | 548 | * |
| 240 | 549 | * @param \WP $wp The WP request object. |
| @@ -242,16 +551,30 @@ | ||
| 242 | 551 | */ |
| 243 | 552 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 244 | 553 | // OAuth discovery documents (served at the site root). |
| 245 | 554 | if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { |
| 246 | - if ( ! self::is_enabled() ) { | |
| 555 | + // The path after the document type names the RESOURCE being | |
| 556 | + // discovered, and it used to be discarded (#516). Resolve it to | |
| 557 | + // the site that actually owns it, which on subdirectory multisite | |
| 558 | + // is how /ca/thinkrank/mcp stops being answered by the main site | |
| 559 | + // with endpoints that have no /ca/ in them. | |
| 560 | + $target = self::resolve_wellknown_target( $wp ); | |
| 561 | + | |
| 562 | + if ( null === $target ) { | |
| 247 | 563 | status_header( 404 ); |
| 248 | 564 | exit; |
| 249 | 565 | } |
| 250 | - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; | |
| 251 | - $data = 'authorization-server' === $doc | |
| 252 | - ? Mcp_OAuth::authorization_server_metadata() | |
| 253 | - : Mcp_OAuth::protected_resource_metadata(); | |
| 566 | + | |
| 567 | + $data = self::discovery_document_for( | |
| 568 | + $target, | |
| 569 | + (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] | |
| 570 | + ); | |
| 571 | + | |
| 572 | + if ( null === $data ) { | |
| 573 | + status_header( 404 ); | |
| 574 | + exit; | |
| 575 | + } | |
| 576 | + | |
| 254 | 577 | status_header( 200 ); |
| 255 | 578 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 256 | 579 | // Discovery metadata is public + cacheable. |
| 257 | 580 | header( 'Cache-Control: public, max-age=3600' ); |
| @@ -272,8 +595,15 @@ | ||
| 272 | 595 | if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { |
| 273 | 596 | return; |
| 274 | 597 | } |
| 275 | 598 | |
| 599 | + // Mark the request so work that only front-end traffic must not pay for | |
| 600 | + // can tell this apart from an anonymous page view. An MCP call is a | |
| 601 | + // deliberate admin-equivalent action, but it arrives on the pretty | |
| 602 | + // /thinkrank/mcp route, so it is neither is_admin() nor REST_REQUEST | |
| 603 | + // and was invisible to those checks (#764). | |
| 604 | + self::$serving_request = true; | |
| 605 | + | |
| 276 | 606 | $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); |
| 277 | 607 | $request->set_header( 'content-type', 'application/json' ); |
| 278 | 608 | // Carry the auth header + raw body from the live PHP request. |
| 279 | 609 | $auth = self::server_header( 'authorization' ); |
| @@ -740,14 +1070,14 @@ | ||
| 740 | 1070 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed. |
| 741 | 1071 | $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); |
| 742 | 1072 | // Params come from GET on the consent link and POST on the form submit. |
| 743 | 1073 | // Nonce is verified below before any POST value is acted on. |
| 744 | - // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each member is sanitize_text_field()ed in the loop below; nothing reads $source directly. | |
| 1074 | + // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead. | |
| 745 | 1075 | $source = $is_post ? $_POST : $_GET; |
| 746 | 1076 | // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 747 | 1077 | $params = []; |
| 748 | 1078 | foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) { |
| 749 | - $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : ''; | |
| 1079 | + $params[ $k ] = self::oauth_param( $source, $k ); | |
| 750 | 1080 | } |
| 751 | 1081 | |
| 752 | 1082 | // Validate the OAuth params before touching the session. |
| 753 | 1083 | $req = Mcp_OAuth::validate_authorize_request( $params ); |
| @@ -979,17 +1309,93 @@ | ||
| 979 | 1309 | |
| 980 | 1310 | // -- Helpers -- |
| 981 | 1311 | |
| 982 | 1312 | /** |
| 1313 | + * Read one /authorize parameter verbatim. | |
| 1314 | + * | |
| 1315 | + * Deliberately NOT sanitize_text_field(). That function exists to make | |
| 1316 | + * untrusted text safe to store and display, and part of what it does is | |
| 1317 | + * strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth | |
| 1318 | + * protocol value it quietly changes the value's meaning. | |
| 1319 | + * | |
| 1320 | + * The concrete failure: registration stores redirect_uris raw from a JSON | |
| 1321 | + * body, but at /authorize the same URI arrives as a query parameter, so | |
| 1322 | + * PHP has already URL-decoded it — and sanitising then removed the percent | |
| 1323 | + * sequences. A client registered with `.../cb?next=%2Fdashboard` was | |
| 1324 | + * compared as `.../cb?next=dashboard`, failed the strict match, and was | |
| 1325 | + * told `invalid_redirect_uri` for sending exactly what it registered | |
| 1326 | + * (#487). `state` has the same problem: it is opaque to us and must | |
| 1327 | + * round-trip byte for byte, or the client aborts its own callback. | |
| 1328 | + * | |
| 1329 | + * Protocol identifiers want validation and rejection, not cleaning. Every | |
| 1330 | + * value read here is constrained somewhere better suited to it: | |
| 1331 | + * - redirect_uri strict in_array() against the client's registered set | |
| 1332 | + * - client_id must resolve to a registered client | |
| 1333 | + * - response_type must equal 'code' | |
| 1334 | + * - code_challenge_method must equal 'S256' | |
| 1335 | + * - code_challenge validated against the RFC 7636 character set | |
| 1336 | + * - scope intersected with SUPPORTED_SCOPES | |
| 1337 | + * - state opaque; escaped at output (esc_attr / rawurlencode) | |
| 1338 | + * - approve/deny tested for emptiness only | |
| 1339 | + * - the nonce passed to wp_verify_nonce() | |
| 1340 | + * | |
| 1341 | + * An array value (`?state[]=x`) reads as absent rather than becoming the | |
| 1342 | + * string "Array". | |
| 1343 | + * | |
| 1344 | + * @since 2.1.0 | |
| 1345 | + * | |
| 1346 | + * @param array<string,mixed> $source $_GET or $_POST. | |
| 1347 | + * @param string $key Parameter name. | |
| 1348 | + * @return string | |
| 1349 | + */ | |
| 1350 | + private static function oauth_param( array $source, string $key ): string { | |
| 1351 | + if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) { | |
| 1352 | + return ''; | |
| 1353 | + } | |
| 1354 | + | |
| 1355 | + return (string) wp_unslash( $source[ $key ] ); | |
| 1356 | + } | |
| 1357 | + | |
| 1358 | + /** | |
| 983 | 1359 | * Read an inbound HTTP header from $_SERVER (for the pretty path). |
| 984 | 1360 | * |
| 1361 | + * Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the | |
| 1362 | + * Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own | |
| 1363 | + * .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a | |
| 1364 | + * few Apache module setups populate neither key but do answer getallheaders(). | |
| 1365 | + * The REST route gets this handling from core; the pretty route builds its own | |
| 1366 | + * WP_REST_Request, so it has to do the same here or it 401s on those hosts. | |
| 1367 | + * | |
| 985 | 1368 | * @param string $name Header name. |
| 986 | 1369 | * @return string|null |
| 987 | 1370 | */ |
| 988 | 1371 | private static function server_header( string $name ): ?string { |
| 989 | 1372 | $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); |
| 1373 | + | |
| 990 | 1374 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. |
| 991 | - return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; | |
| 1375 | + if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) { | |
| 1376 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1377 | + return wp_unslash( $_SERVER[ $key ] ); | |
| 1378 | + } | |
| 1379 | + | |
| 1380 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1381 | + if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) { | |
| 1382 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1383 | + return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] ); | |
| 1384 | + } | |
| 1385 | + | |
| 1386 | + if ( function_exists( 'getallheaders' ) ) { | |
| 1387 | + $headers = getallheaders(); | |
| 1388 | + if ( is_array( $headers ) ) { | |
| 1389 | + foreach ( $headers as $header => $value ) { | |
| 1390 | + if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) { | |
| 1391 | + return (string) $value; | |
| 1392 | + } | |
| 1393 | + } | |
| 1394 | + } | |
| 1395 | + } | |
| 1396 | + | |
| 1397 | + return null; | |
| 992 | 1398 | } |
| 993 | 1399 | |
| 994 | 1400 | /** |
| 995 | 1401 | * Emit a WP_REST_Response as a JSON HTTP response and stop. |