| 1 |
<?php |
| 2 |
/** |
| 3 |
* The built-in MCP endpoint (spec 044, FR-001, FR-007, FR-008, FR-041). |
| 4 |
* |
| 5 |
* Routes: |
| 6 |
* POST /wp-json/templately/v1/mcp JSON-RPC |
| 7 |
* POST /templately/mcp pretty alias (rewrite) |
| 8 |
* GET /.well-known/oauth-protected-resource discovery |
| 9 |
* GET /.well-known/oauth-authorization-server discovery |
| 10 |
* GET|POST /templately/authorize approval screen (front-end page) |
| 11 |
* |
| 12 |
* No collision with the mcp-adapter's `/wp-json/templately/mcp` (namespace |
| 13 |
* `templately`, route `mcp`): this lives under `templately/v1`. Both may serve |
| 14 |
* the same capability set concurrently (FR-007). |
| 15 |
* |
| 16 |
* Registered with register_rest_route() DIRECTLY rather than through |
| 17 |
* API::register_endpoint(), which force-injects its own `_permission_check` |
| 18 |
* and would fight AuthManager. |
| 19 |
* |
| 20 |
* @package Templately\Modules\McpServer\Server |
| 21 |
*/ |
| 22 |
|
| 23 |
namespace Templately\Modules\McpServer\Server; |
| 24 |
|
| 25 |
use Templately\Modules\McpServer\Auth\AuthManager; |
| 26 |
use Templately\Modules\McpServer\Auth\Credentials; |
| 27 |
use Templately\Modules\McpServer\Auth\FailedAuthLimiter; |
| 28 |
use Templately\Modules\McpServer\Auth\OAuth\ConsentScreen; |
| 29 |
use Templately\Modules\McpServer\Auth\OAuth\OAuthServer; |
| 30 |
use Templately\Modules\McpCore\Registry\ToolDescriptor; |
| 31 |
use Templately\Utils\Base; |
| 32 |
use WP_Error; |
| 33 |
use WP_REST_Request; |
| 34 |
use WP_REST_Response; |
| 35 |
|
| 36 |
class HttpTransport extends Base { |
| 37 |
|
| 38 |
const NAMESPACE = 'templately/v1'; |
| 39 |
const ROUTE = '/mcp'; |
| 40 |
|
| 41 |
/** Rewrite rules this feature owns. Used by the self-healing flush check. */ |
| 42 |
const REWRITES = [ |
| 43 |
'^templately/mcp/?$' => 'index.php?templately_mcp=1', |
| 44 |
'^templately/authorize/?$' => 'index.php?templately_mcp_authorize=1', |
| 45 |
|
| 46 |
// NOTE: the BARE `/.well-known/oauth-protected-resource` and |
| 47 |
// `/.well-known/oauth-authorization-server` are deliberately NOT claimed. |
| 48 |
// |
| 49 |
// Those are one global slot per origin, and the documents we would serve |
| 50 |
// there are not even valid at that address: RFC 8414 §3.3 requires the |
| 51 |
// returned `issuer` to be identical to the issuer whose metadata was |
| 52 |
// retrieved, and RFC 9728 the same for `resource`. Ours are |
| 53 |
// `<origin>/templately` and `<origin>/wp-json/templately/v1/mcp`, so a |
| 54 |
// strict client fetching the bare path would rightly reject the answer. |
| 55 |
// |
| 56 |
// Claiming them anyway is what let two MCP plugins fight over discovery. |
| 57 |
// Nothing needs them: the 401 challenge names the resource-specific |
| 58 |
// metadata URL directly (AuthManager::challenge_header), that document |
| 59 |
// names the scoped issuer, and the issuer resolves to its own slot below. |
| 60 |
|
| 61 |
// RFC 9728 §3.1: a client appends the RESOURCE PATH to the well-known |
| 62 |
// prefix, so an MCP client configured with |
| 63 |
// `…/wp-json/templately/v1/mcp` actually requests |
| 64 |
// `/.well-known/oauth-protected-resource/wp-json/templately/v1/mcp`. |
| 65 |
// Serving only the bare path above meant that request fell through to |
| 66 |
// whatever else on the site claimed `.well-known` — on a site that also |
| 67 |
// runs another MCP plugin, that plugin answered FOR THIS RESOURCE and |
| 68 |
// handed the client its own authorization endpoint, so approval happened |
| 69 |
// somewhere else entirely. Observed live with ChatGPT. |
| 70 |
// |
| 71 |
// Deliberately matched to THIS plugin's own resource paths rather than a |
| 72 |
// wildcard: a broad rule would hijack the neighbour's discovery exactly |
| 73 |
// as ours was hijacked. |
| 74 |
'^\.well-known/oauth-(protected-resource|authorization-server)/.*templately/(?:v1/)?mcp/?$' |
| 75 |
=> 'index.php?templately_mcp_wellknown=$matches[1]', |
| 76 |
|
| 77 |
// The PATH-SCOPED issuer's own metadata slot. RFC 8414 §3.1 builds the |
| 78 |
// URL for an issuer with a path by inserting the well-known segment |
| 79 |
// between host and path, so issuer `https://site/templately` publishes at |
| 80 |
// `/.well-known/oauth-authorization-server/templately`. |
| 81 |
// |
| 82 |
// This is the slot that makes coexistence possible: the bare |
| 83 |
// `/.well-known/oauth-authorization-server` is a SINGLE global slot per |
| 84 |
// origin, which two MCP plugins cannot both own — we were winning it by |
| 85 |
// rewrite-registration order, not by right. |
| 86 |
'^\.well-known/oauth-authorization-server/templately/?$' |
| 87 |
=> 'index.php?templately_mcp_wellknown=authorization-server', |
| 88 |
]; |
| 89 |
|
| 90 |
/** |
| 91 |
* Routes that speak a FOREIGN protocol and must never be enveloped. |
| 92 |
* |
| 93 |
* Matched exactly, not by prefix: `/mcp/connection*` shares the `/mcp` prefix |
| 94 |
* but is an ordinary Templately REST route consumed by our own settings SPA, |
| 95 |
* so it MUST keep the envelope. |
| 96 |
*/ |
| 97 |
const PROTOCOL_ROUTES = [ |
| 98 |
'/' . self::NAMESPACE . '/mcp', // JSON-RPC 2.0 |
| 99 |
'/' . self::NAMESPACE . '/mcp/oauth/register', // RFC 7591 |
| 100 |
'/' . self::NAMESPACE . '/mcp/oauth/token', // RFC 6749 §5 |
| 101 |
]; |
| 102 |
|
| 103 |
/** @var array|null Auth context resolved in the permission callback. */ |
| 104 |
private $context = null; |
| 105 |
|
| 106 |
public function __construct() { |
| 107 |
add_action( 'rest_api_init', [ $this, 'register_routes' ] ); |
| 108 |
// Keep the spec-043 envelope off this module's protocol routes — see |
| 109 |
// exempt_protocol_routes(). Registered in the constructor (plugins_loaded) |
| 110 |
// so it is in place before any request is dispatched. |
| 111 |
add_filter( 'templately_rest_envelope_owns_route', [ $this, 'exempt_protocol_routes' ], 10, 2 ); |
| 112 |
// WP_Error data does not become response headers on its own, and the |
| 113 |
// WWW-Authenticate pointer is what lets a URL-only client bootstrap |
| 114 |
// (FR-029) — without it a 401 is a dead end. |
| 115 |
// Priority 20, NOT 10: core's own `rest_send_allow_header` is also on |
| 116 |
// `rest_post_dispatch` at 10, and this class is constructed on |
| 117 |
// `plugins_loaded` — i.e. registered first — so at equal priority ours |
| 118 |
// would run before core's and be overwritten by it. |
| 119 |
add_filter( 'rest_post_dispatch', [ $this, 'attach_auth_headers' ], 20, 3 ); |
| 120 |
add_action( 'init', [ $this, 'add_rewrites' ] ); |
| 121 |
add_filter( 'query_vars', [ $this, 'add_query_vars' ] ); |
| 122 |
add_action( 'parse_request', [ $this, 'maybe_handle_pretty_request' ] ); |
| 123 |
} |
| 124 |
|
| 125 |
public function register_routes(): void { |
| 126 |
register_rest_route( |
| 127 |
self::NAMESPACE, |
| 128 |
self::ROUTE, |
| 129 |
[ |
| 130 |
[ |
| 131 |
'methods' => 'POST', |
| 132 |
'callback' => [ $this, 'handle' ], |
| 133 |
'permission_callback' => [ $this, 'authorize' ], |
| 134 |
], |
| 135 |
[ |
| 136 |
// GET/DELETE are the streamable-HTTP session verbs: GET opens an |
| 137 |
// SSE stream, DELETE terminates a session. This server is |
| 138 |
// stateless and serves no SSE (spec 044, Out of Scope), and the |
| 139 |
// protocol says a server that does not offer a stream at this |
| 140 |
// endpoint MUST answer 405 — not 404. |
| 141 |
// |
| 142 |
// Without this, WordPress answers an unmatched method with a |
| 143 |
// generic `rest_no_route` 404, which reads as "this feature is |
| 144 |
// not installed" to anyone who pastes the endpoint into a |
| 145 |
// browser. 405 says "right address, wrong verb". |
| 146 |
'methods' => 'GET, DELETE', |
| 147 |
'callback' => [ $this, 'method_not_allowed' ], |
| 148 |
'permission_callback' => '__return_true', |
| 149 |
], |
| 150 |
] |
| 151 |
); |
| 152 |
|
| 153 |
OAuthServer::register_routes(); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* @return WP_REST_Response |
| 158 |
*/ |
| 159 |
public function method_not_allowed(): WP_REST_Response { |
| 160 |
$response = new WP_REST_Response( |
| 161 |
JsonRpc::error( |
| 162 |
null, |
| 163 |
JsonRpc::INVALID_REQUEST, |
| 164 |
__( 'This MCP endpoint accepts POST only. Configure it in an MCP client rather than opening it in a browser.', 'templately' ) |
| 165 |
), |
| 166 |
405 |
| 167 |
); |
| 168 |
|
| 169 |
// NOTE: `Allow` is (re)set in attach_auth_headers(), not here — the REST |
| 170 |
// server overwrites it after dispatch with the matched route's own |
| 171 |
// methods, which would advertise "GET, DELETE": precisely the verbs that |
| 172 |
// are NOT allowed. |
| 173 |
// |
| 174 |
// Still advertise where to authenticate, so a client probing with GET can |
| 175 |
// discover the flow instead of dead-ending. |
| 176 |
$response->header( 'WWW-Authenticate', AuthManager::challenge_header() ); |
| 177 |
|
| 178 |
return $response; |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Preconditions + identity, in a load-bearing order (contracts/mcp-endpoint.md): |
| 183 |
* |
| 184 |
* 1. lockout — BEFORE any secret comparison, so a locked source |
| 185 |
* cannot use the endpoint as a guessing oracle |
| 186 |
* 2. content type — reject non-agent submissions |
| 187 |
* 3. origin — reject cross-site browser submissions |
| 188 |
* 4. inert check — refuse while the site holds no credential |
| 189 |
* 5. credential — resolve and establish the acting user |
| 190 |
* |
| 191 |
* @param WP_REST_Request $request |
| 192 |
* @return true|WP_Error |
| 193 |
*/ |
| 194 |
public function authorize( WP_REST_Request $request ) { |
| 195 |
if ( FailedAuthLimiter::is_locked() ) { |
| 196 |
return new WP_Error( |
| 197 |
'too_many_requests', |
| 198 |
__( 'Too many failed authentication attempts. Try again later.', 'templately' ), |
| 199 |
[ |
| 200 |
'status' => 429, |
| 201 |
'retry_after' => FailedAuthLimiter::retry_after(), |
| 202 |
] |
| 203 |
); |
| 204 |
} |
| 205 |
|
| 206 |
$content_type = (string) $request->get_header( 'content-type' ); |
| 207 |
|
| 208 |
if ( '' !== $content_type && false === stripos( $content_type, 'application/json' ) ) { |
| 209 |
return new WP_Error( |
| 210 |
'unsupported_media_type', |
| 211 |
__( 'This endpoint accepts application/json only.', 'templately' ), |
| 212 |
[ 'status' => 415 ] |
| 213 |
); |
| 214 |
} |
| 215 |
|
| 216 |
$origin = (string) $request->get_header( 'origin' ); |
| 217 |
|
| 218 |
if ( '' !== $origin && ! $this->origin_is_same_site( $origin ) ) { |
| 219 |
return new WP_Error( |
| 220 |
'forbidden_origin', |
| 221 |
__( 'Cross-origin requests are not accepted by this endpoint.', 'templately' ), |
| 222 |
[ 'status' => 403 ] |
| 223 |
); |
| 224 |
} |
| 225 |
|
| 226 |
// Inert until an administrator explicitly connects (FR-023). |
| 227 |
if ( ! Credentials::site_has_any() ) { |
| 228 |
return $this->unauthorized( __( 'This site has no agent connection configured.', 'templately' ) ); |
| 229 |
} |
| 230 |
|
| 231 |
$context = AuthManager::resolve(); |
| 232 |
|
| 233 |
if ( null === $context ) { |
| 234 |
return $this->unauthorized( __( 'Invalid or missing connection credential.', 'templately' ) ); |
| 235 |
} |
| 236 |
|
| 237 |
$this->context = $context; |
| 238 |
|
| 239 |
return true; |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* @param string $message |
| 244 |
* @return WP_Error |
| 245 |
*/ |
| 246 |
private function unauthorized( string $message ): WP_Error { |
| 247 |
return new WP_Error( |
| 248 |
'unauthorized', |
| 249 |
$message, |
| 250 |
[ |
| 251 |
'status' => 401, |
| 252 |
'www_authenticate' => AuthManager::challenge_header(), |
| 253 |
] |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* @param string $origin |
| 259 |
* @return bool |
| 260 |
*/ |
| 261 |
private function origin_is_same_site( string $origin ): bool { |
| 262 |
$origin_host = wp_parse_url( $origin, PHP_URL_HOST ); |
| 263 |
$site_host = wp_parse_url( home_url(), PHP_URL_HOST ); |
| 264 |
|
| 265 |
return is_string( $origin_host ) && is_string( $site_host ) |
| 266 |
&& strtolower( $origin_host ) === strtolower( $site_host ); |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* @param WP_REST_Request $request |
| 271 |
* @return WP_REST_Response |
| 272 |
*/ |
| 273 |
public function handle( WP_REST_Request $request ) { |
| 274 |
$body = $request->get_body(); |
| 275 |
$decoded = json_decode( $body, true ); |
| 276 |
|
| 277 |
if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { |
| 278 |
return new WP_REST_Response( |
| 279 |
JsonRpc::error( null, JsonRpc::PARSE_ERROR, __( 'Could not parse request body as JSON.', 'templately' ) ), |
| 280 |
200 |
| 281 |
); |
| 282 |
} |
| 283 |
|
| 284 |
// Batching was removed in the supported protocol revision (FR-004). The |
| 285 |
// reference implementation still accepts it. |
| 286 |
if ( ! JsonRpc::is_valid_request( $decoded ) ) { |
| 287 |
return new WP_REST_Response( |
| 288 |
JsonRpc::error( |
| 289 |
null, |
| 290 |
JsonRpc::INVALID_REQUEST, |
| 291 |
is_array( $decoded ) && isset( $decoded[0] ) |
| 292 |
? __( 'JSON-RPC batching is not supported.', 'templately' ) |
| 293 |
: __( 'Not a valid JSON-RPC request.', 'templately' ) |
| 294 |
), |
| 295 |
200 |
| 296 |
); |
| 297 |
} |
| 298 |
|
| 299 |
$access_level = (string) ( $this->context['access_level'] ?? ToolDescriptor::ACCESS_FULL ); |
| 300 |
$credential_id = $this->context['credential_id'] ?? null; |
| 301 |
|
| 302 |
$response = McpServer::dispatch( $decoded, $access_level, $credential_id ); |
| 303 |
|
| 304 |
// Notification — accepted, no body (FR-002). |
| 305 |
if ( null === $response ) { |
| 306 |
return new WP_REST_Response( null, 202 ); |
| 307 |
} |
| 308 |
|
| 309 |
return new WP_REST_Response( $response, 200 ); |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* Refuse the spec-043 envelope on the three routes that answer to a protocol |
| 314 |
* this plugin does not own. |
| 315 |
* |
| 316 |
* `RestEnvelope` claims the whole `templately/v1` namespace, and these routes |
| 317 |
* live in it — so without this every JSON-RPC reply ships as |
| 318 |
* `{"success":true,"data":{"jsonrpc":"2.0",…}}` and every token response as |
| 319 |
* `{"success":true,"data":{"access_token":…}}`. Neither is parseable by the |
| 320 |
* clients they are for, and an OAuth error body loses the RFC-required `error` |
| 321 |
* field to this plugin's own error vocabulary. |
| 322 |
* |
| 323 |
* The damage is not only to the body. The envelope rebuilds a WP_Error's data |
| 324 |
* bag from a fixed key set, dropping `www_authenticate` — which |
| 325 |
* `attach_auth_headers()` reads back to emit the `WWW-Authenticate` challenge. |
| 326 |
* That challenge is how a URL-only client discovers where to authenticate |
| 327 |
* (FR-029), so losing it dead-ends the whole delegated-approval flow. |
| 328 |
* |
| 329 |
* None of this was visible in the test suite: every MCP test drives the |
| 330 |
* endpoint with `rest_do_request()`, which does not run `rest_post_dispatch`, |
| 331 |
* so the envelope never applied there. It applies over real HTTP, on exactly |
| 332 |
* the endpoint the settings screen and the docs tell users to configure. |
| 333 |
* |
| 334 |
* @param bool $owned |
| 335 |
* @param string $route |
| 336 |
* @return bool |
| 337 |
*/ |
| 338 |
public function exempt_protocol_routes( $owned, $route ) { |
| 339 |
return $owned && ! in_array( untrailingslashit( (string) $route ), self::PROTOCOL_ROUTES, true ); |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* Promote auth metadata from the WP_Error payload into real headers. |
| 344 |
* |
| 345 |
* @param WP_REST_Response $response |
| 346 |
* @param mixed $server |
| 347 |
* @param WP_REST_Request $request |
| 348 |
* @return WP_REST_Response |
| 349 |
*/ |
| 350 |
public function attach_auth_headers( $response, $server, $request ) { |
| 351 |
if ( ! $response instanceof WP_REST_Response || ! $request instanceof WP_REST_Request ) { |
| 352 |
return $response; |
| 353 |
} |
| 354 |
|
| 355 |
if ( 0 !== strpos( (string) $request->get_route(), '/' . self::NAMESPACE . '/mcp' ) ) { |
| 356 |
return $response; |
| 357 |
} |
| 358 |
|
| 359 |
// Correct the Allow header the REST server writes after dispatch: it lists |
| 360 |
// the matched handler's methods (GET, DELETE) when those are exactly the |
| 361 |
// ones being refused. POST is what a caller should use. |
| 362 |
if ( 405 === $response->get_status() ) { |
| 363 |
$response->header( 'Allow', 'POST', true ); |
| 364 |
} |
| 365 |
|
| 366 |
$data = $response->get_data(); |
| 367 |
|
| 368 |
if ( ! is_array( $data ) || empty( $data['data'] ) || ! is_array( $data['data'] ) ) { |
| 369 |
return $response; |
| 370 |
} |
| 371 |
|
| 372 |
if ( ! empty( $data['data']['www_authenticate'] ) ) { |
| 373 |
$response->header( 'WWW-Authenticate', (string) $data['data']['www_authenticate'] ); |
| 374 |
} |
| 375 |
|
| 376 |
if ( ! empty( $data['data']['retry_after'] ) ) { |
| 377 |
$response->header( 'Retry-After', (string) (int) $data['data']['retry_after'] ); |
| 378 |
} |
| 379 |
|
| 380 |
return $response; |
| 381 |
} |
| 382 |
|
| 383 |
// ---------------------------------------------------------------- rewrites |
| 384 |
|
| 385 |
public function add_rewrites(): void { |
| 386 |
foreach ( self::REWRITES as $pattern => $target ) { |
| 387 |
add_rewrite_rule( $pattern, $target, 'top' ); |
| 388 |
} |
| 389 |
|
| 390 |
$this->maybe_flush_rewrites(); |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Self-healing flush (FR-008). Compares EVERY rule this feature needs, not |
| 395 |
* just one — a single-rule check is exactly what let the reference |
| 396 |
* implementation ship a state where discovery 404'd permanently because an |
| 397 |
* earlier partial flush had left one rule present and the rest missing. |
| 398 |
* |
| 399 |
* @return void |
| 400 |
*/ |
| 401 |
private function maybe_flush_rewrites(): void { |
| 402 |
$rules = get_option( 'rewrite_rules' ); |
| 403 |
|
| 404 |
if ( ! is_array( $rules ) ) { |
| 405 |
return; |
| 406 |
} |
| 407 |
|
| 408 |
foreach ( array_keys( self::REWRITES ) as $pattern ) { |
| 409 |
if ( ! isset( $rules[ $pattern ] ) ) { |
| 410 |
flush_rewrite_rules( false ); |
| 411 |
|
| 412 |
return; |
| 413 |
} |
| 414 |
} |
| 415 |
} |
| 416 |
|
| 417 |
/** |
| 418 |
* @param array $vars |
| 419 |
* @return array |
| 420 |
*/ |
| 421 |
public function add_query_vars( array $vars ): array { |
| 422 |
$vars[] = 'templately_mcp'; |
| 423 |
$vars[] = 'templately_mcp_authorize'; |
| 424 |
$vars[] = 'templately_mcp_wellknown'; |
| 425 |
|
| 426 |
return $vars; |
| 427 |
} |
| 428 |
|
| 429 |
/** |
| 430 |
* Serve the pretty paths. The JSON-RPC alias synthesizes a WP_REST_Request |
| 431 |
* and reuses the same handler rather than duplicating dispatch. |
| 432 |
* |
| 433 |
* @param \WP $wp |
| 434 |
* @return void |
| 435 |
*/ |
| 436 |
public function maybe_handle_pretty_request( $wp ): void { |
| 437 |
$vars = isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : []; |
| 438 |
|
| 439 |
if ( ! empty( $vars['templately_mcp_wellknown'] ) ) { |
| 440 |
OAuthServer::serve_discovery( (string) $vars['templately_mcp_wellknown'] ); |
| 441 |
|
| 442 |
return; |
| 443 |
} |
| 444 |
|
| 445 |
// The approval screen must be a normal front-end page, NOT a REST route: |
| 446 |
// a browser returning from wp-login carries a cookie but no REST nonce, |
| 447 |
// so inside REST it does not read as logged in and the login redirect |
| 448 |
// loops forever (FR-032). |
| 449 |
if ( ! empty( $vars['templately_mcp_authorize'] ) ) { |
| 450 |
ConsentScreen::render(); |
| 451 |
|
| 452 |
return; |
| 453 |
} |
| 454 |
|
| 455 |
if ( empty( $vars['templately_mcp'] ) ) { |
| 456 |
return; |
| 457 |
} |
| 458 |
|
| 459 |
// POST only. This path bypasses the REST server's own method routing, so |
| 460 |
// without an explicit check a GET navigation reached the handler. |
| 461 |
if ( 'POST' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) ) { |
| 462 |
$this->emit_error( |
| 463 |
new WP_Error( |
| 464 |
'method_not_allowed', |
| 465 |
__( 'This MCP endpoint accepts POST only.', 'templately' ), |
| 466 |
[ 'status' => 405 ] |
| 467 |
) |
| 468 |
); |
| 469 |
} |
| 470 |
|
| 471 |
$request = new WP_REST_Request( 'POST', '/' . self::NAMESPACE . self::ROUTE ); |
| 472 |
|
| 473 |
// The REAL content type, not a hardcoded one. Hardcoding it made |
| 474 |
// authorize()'s content-type gate — documented above as step 2 of a |
| 475 |
// load-bearing order — a no-op on this route: it could never observe |
| 476 |
// anything but application/json, so the whole check was dead code here |
| 477 |
// and cross-origin protection rested on the Origin check alone. |
| 478 |
$request->set_header( |
| 479 |
'content-type', |
| 480 |
isset( $_SERVER['CONTENT_TYPE'] ) |
| 481 |
? sanitize_text_field( wp_unslash( $_SERVER['CONTENT_TYPE'] ) ) |
| 482 |
: '' |
| 483 |
); |
| 484 |
|
| 485 |
foreach ( [ 'authorization', 'origin' ] as $header ) { |
| 486 |
$key = 'HTTP_' . strtoupper( str_replace( '-', '_', $header ) ); |
| 487 |
|
| 488 |
if ( ! empty( $_SERVER[ $key ] ) ) { |
| 489 |
$request->set_header( $header, sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); |
| 490 |
} |
| 491 |
} |
| 492 |
|
| 493 |
$request->set_body( file_get_contents( 'php://input' ) ); |
| 494 |
|
| 495 |
$permission = $this->authorize( $request ); |
| 496 |
|
| 497 |
if ( is_wp_error( $permission ) ) { |
| 498 |
$this->emit_error( $permission ); |
| 499 |
} |
| 500 |
|
| 501 |
$response = $this->handle( $request ); |
| 502 |
|
| 503 |
status_header( $response->get_status() ); |
| 504 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 505 |
nocache_headers(); |
| 506 |
|
| 507 |
$data = $response->get_data(); |
| 508 |
|
| 509 |
if ( null !== $data ) { |
| 510 |
echo wp_json_encode( $data ); |
| 511 |
} |
| 512 |
|
| 513 |
exit; |
| 514 |
} |
| 515 |
|
| 516 |
/** |
| 517 |
* @param WP_Error $error |
| 518 |
* @return void |
| 519 |
*/ |
| 520 |
private function emit_error( WP_Error $error ): void { |
| 521 |
$data = $error->get_error_data(); |
| 522 |
$status = (int) ( $data['status'] ?? 401 ); |
| 523 |
|
| 524 |
status_header( $status ); |
| 525 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 526 |
|
| 527 |
if ( ! empty( $data['www_authenticate'] ) ) { |
| 528 |
header( 'WWW-Authenticate: ' . $data['www_authenticate'] ); |
| 529 |
} |
| 530 |
|
| 531 |
if ( ! empty( $data['retry_after'] ) ) { |
| 532 |
header( 'Retry-After: ' . (int) $data['retry_after'] ); |
| 533 |
} |
| 534 |
|
| 535 |
echo wp_json_encode( |
| 536 |
JsonRpc::error( null, JsonRpc::UNAUTHORIZED, $error->get_error_message() ) |
| 537 |
); |
| 538 |
|
| 539 |
exit; |
| 540 |
} |
| 541 |
} |
| 542 |
|