| @@ -91,8 +91,17 @@ | ||
| 91 | 91 | */ |
| 92 | 92 | const AUTHORIZE_QUERY_VAR = 'betterdocs_mcp_authorize'; |
| 93 | 93 | |
| 94 | 94 | /** |
| 95 | + * Bumped whenever the rewrite rule *set* changes shape, to force a single | |
| 96 | + * flush that evicts rules retired in an earlier version. Stored against | |
| 97 | + * `betterdocs_mcp_rewrite_ver`; see {@see self::maybe_flush()}. | |
| 98 | + * | |
| 99 | + * @since 4.9.3 | |
| 100 | + */ | |
| 101 | + const REWRITE_VER = '2'; | |
| 102 | + | |
| 103 | + /** | |
| 95 | 104 | * Whether a rewrite flush has already been triggered this request. |
| 96 | 105 | * |
| 97 | 106 | * @since 4.9.0 |
| 98 | 107 | * |
| @@ -133,8 +142,15 @@ | ||
| 133 | 142 | $this->self_test = $self_test; |
| 134 | 143 | |
| 135 | 144 | add_action( 'init', [ $this, 'add_rewrite' ] ); |
| 136 | 145 | add_filter( 'query_vars', [ $this, 'register_query_vars' ] ); |
| 146 | + // Claim our own discovery URLs on `do_parse_request`, which runs before | |
| 147 | + // the rewrite table is even consulted, so another plugin's broad | |
| 148 | + // `.well-known/oauth-*` catch-all rewrite cannot answer BetterDocs' own | |
| 149 | + // discovery URL with its `resource`. Scoped to `betterdocs/mcp` only — | |
| 150 | + // this never intercepts anyone else's path. Priority 0 so it wins over a | |
| 151 | + // rival that also hooks here late. | |
| 152 | + add_filter( 'do_parse_request', [ $this, 'serve_own_discovery' ], 0 ); | |
| 137 | 153 | add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] ); |
| 138 | 154 | add_action( 'rest_api_init', [ $this, 'register_rest' ] ); |
| 139 | 155 | add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] ); |
| 140 | 156 | |
| @@ -182,19 +198,18 @@ | ||
| 182 | 198 | foreach ( self::rules() as $regex => $query ) { |
| 183 | 199 | add_rewrite_rule( $regex, $query, 'top' ); |
| 184 | 200 | } |
| 185 | 201 | |
| 186 | - // Root-form fallback, for clients that only ever try the bare | |
| 187 | - // well-known URL. Harmless when another plugin registers the same | |
| 188 | - // regex — last registrant wins, and our own clients use the | |
| 189 | - // path-suffixed form above. Deliberately outside self::rules(), so a | |
| 190 | - // plugin that took it from us does not make us flush on every request. | |
| 191 | - add_rewrite_rule( | |
| 192 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 193 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 194 | - 'top' | |
| 195 | - ); | |
| 196 | - | |
| 202 | + // No broad `(?:/.*)?` catch-all: that regex is identical across every | |
| 203 | + // plugin built on this transport, so it becomes a single shared rewrite | |
| 204 | + // key whose winner answers *every* plugin's `/.well-known/oauth-*/*` | |
| 205 | + // discovery URL — returning its own `resource` for a path it does not | |
| 206 | + // own, which RFC 9728 clients reject on the exact-match check. Each rule | |
| 207 | + // in self::rules() is scoped to `betterdocs/mcp`, so only our own | |
| 208 | + // discovery URLs route here. The bare-root form is intentionally not | |
| 209 | + // served: RFC 9728 clients derive the path-suffixed URL from the MCP | |
| 210 | + // endpoint, and a suffix-less URL cannot disambiguate two MCP plugins on | |
| 211 | + // one site anyway. | |
| 197 | 212 | self::maybe_flush(); |
| 198 | 213 | } |
| 199 | 214 | |
| 200 | 215 | /** |
| @@ -231,8 +246,21 @@ | ||
| 231 | 246 | if ( self::$flushed ) { |
| 232 | 247 | return; |
| 233 | 248 | } |
| 234 | 249 | |
| 250 | + // One-time flush when the rule set changes shape between versions. The | |
| 251 | + // missing-rule check below only *adds* rules; it never evicts one that | |
| 252 | + // was removed — such as the retired `(?:/.*)?` catch-all, which would | |
| 253 | + // otherwise linger in the stored table and keep answering other plugins' | |
| 254 | + // discovery URLs until permalinks were re-saved by hand. | |
| 255 | + if ( self::REWRITE_VER !== (string) get_option( 'betterdocs_mcp_rewrite_ver', '' ) ) { | |
| 256 | + self::$flushed = true; | |
| 257 | + flush_rewrite_rules( false ); | |
| 258 | + update_option( 'betterdocs_mcp_rewrite_ver', self::REWRITE_VER, false ); | |
| 259 | + | |
| 260 | + return; | |
| 261 | + } | |
| 262 | + | |
| 235 | 263 | $rules = get_option( 'rewrite_rules' ); |
| 236 | 264 | |
| 237 | 265 | if ( ! is_array( $rules ) ) { |
| 238 | 266 | return; |
| @@ -269,8 +297,52 @@ | ||
| 269 | 297 | return $vars; |
| 270 | 298 | } |
| 271 | 299 | |
| 272 | 300 | /** |
| 301 | + * Serve BetterDocs' own OAuth discovery documents straight from the request | |
| 302 | + * URI, before WordPress matches any rewrite rule. | |
| 303 | + * | |
| 304 | + * This is what makes the discovery URLs hijack-proof: the rewrite table is a | |
| 305 | + * flat, order-dependent list shared by every plugin, so a plugin whose broad | |
| 306 | + * `.well-known/oauth-*` catch-all happens to sit above our path-specific rule | |
| 307 | + * would otherwise answer our own URL with its `resource`. Matching the URI | |
| 308 | + * here — on `do_parse_request`, before rules are consulted — sidesteps that | |
| 309 | + * ordering entirely. The patterns are anchored to `betterdocs/mcp`, so this | |
| 310 | + * only ever claims BetterDocs' own paths and never intercepts another | |
| 311 | + * plugin's discovery URL. When MCP is off it does nothing and lets the | |
| 312 | + * request fall through. | |
| 313 | + * | |
| 314 | + * @since 4.9.3 | |
| 315 | + * | |
| 316 | + * @param bool $continue Whether WordPress should continue parsing the request. | |
| 317 | + * @return bool The unchanged flag when this is not one of our URLs; otherwise | |
| 318 | + * the response is emitted and the request exits. | |
| 319 | + */ | |
| 320 | + public function serve_own_discovery( $continue ) { | |
| 321 | + if ( ! self::is_enabled() ) { | |
| 322 | + return $continue; | |
| 323 | + } | |
| 324 | + | |
| 325 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, matched with a literal-anchored regex, never stored or output. | |
| 326 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; | |
| 327 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 328 | + | |
| 329 | + // RFC 9728 path-insert form and the OIDC suffix form both name a document. | |
| 330 | + if ( | |
| 331 | + preg_match( '#/\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$#', $path, $m ) | |
| 332 | + || preg_match( '#/betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$#', $path, $m ) | |
| 333 | + ) { | |
| 334 | + $this->emit_discovery( $m[1] ); | |
| 335 | + } | |
| 336 | + | |
| 337 | + if ( preg_match( '#/betterdocs/mcp/\.well-known/openid-configuration/?$#', $path ) ) { | |
| 338 | + $this->emit_discovery( 'authorization-server' ); | |
| 339 | + } | |
| 340 | + | |
| 341 | + return $continue; | |
| 342 | + } | |
| 343 | + | |
| 344 | + /** | |
| 273 | 345 | * Serve the pretty paths. |
| 274 | 346 | * |
| 275 | 347 | * Runs on `parse_request`, before the main query, and short-circuits |
| 276 | 348 | * WordPress entirely. |
| @@ -349,9 +421,20 @@ | ||
| 349 | 421 | * @param string $doc `protected-resource` or `authorization-server`. |
| 350 | 422 | * @return void |
| 351 | 423 | */ |
| 352 | 424 | private function emit_discovery( $doc ) { |
| 353 | - if ( ! self::is_enabled() ) { | |
| 425 | + // Only answer BetterDocs' own discovery URLs. Every rule that sets the | |
| 426 | + // well-known query var carries `betterdocs/mcp` in its path, so a request | |
| 427 | + // that lacks it reached here through some other plugin's catch-all | |
| 428 | + // rewrite — 404 rather than hand back BetterDocs metadata for a resource | |
| 429 | + // we do not own (which an RFC 9728 client would reject anyway). This also | |
| 430 | + // guards the window after an upgrade, before a retired catch-all is | |
| 431 | + // flushed out of the stored rewrite table. | |
| 432 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, compared with strpos, never stored or output. | |
| 433 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; | |
| 434 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 435 | + | |
| 436 | + if ( ! self::is_enabled() || false === strpos( $path, 'betterdocs/mcp' ) ) { | |
| 354 | 437 | status_header( 404 ); |
| 355 | 438 | exit; |
| 356 | 439 | } |
| 357 | 440 | |