| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPGraphQL; |
| 4 |
|
| 5 |
use GraphQL\Error\FormattedError; |
| 6 |
use WP_User; |
| 7 |
|
| 8 |
/** |
| 9 |
* Class Router |
| 10 |
* This sets up the /graphql endpoint |
| 11 |
* |
| 12 |
* @package WPGraphQL |
| 13 |
* @since 0.0.1 |
| 14 |
* |
| 15 |
* phpcs:disable -- PHPStan annotation. |
| 16 |
* @phpstan-import-type SerializableError from \GraphQL\Executor\ExecutionResult |
| 17 |
* @phpstan-import-type SerializableResult from \GraphQL\Executor\ExecutionResult |
| 18 |
* |
| 19 |
* @phpstan-type WPGraphQLResult = SerializableResult|(\GraphQL\Executor\ExecutionResult|array<int,\GraphQL\Executor\ExecutionResult>) |
| 20 |
* phpcs:enable |
| 21 |
*/ |
| 22 |
class Router { |
| 23 |
|
| 24 |
/** |
| 25 |
* Sets the route to use as the endpoint |
| 26 |
* |
| 27 |
* @var string $route |
| 28 |
*/ |
| 29 |
public static $route = 'graphql'; |
| 30 |
|
| 31 |
/** |
| 32 |
* Holds the Global Post for later resetting |
| 33 |
* |
| 34 |
* @var string |
| 35 |
*/ |
| 36 |
protected static $global_post = ''; |
| 37 |
|
| 38 |
/** |
| 39 |
* Set the default status code to 200. |
| 40 |
* |
| 41 |
* @var int |
| 42 |
*/ |
| 43 |
public static $http_status_code = 200; |
| 44 |
|
| 45 |
/** |
| 46 |
* @var ?\WPGraphQL\Request |
| 47 |
*/ |
| 48 |
protected static $request; |
| 49 |
|
| 50 |
/** |
| 51 |
* Initialize the WPGraphQL Router |
| 52 |
* |
| 53 |
* @return void |
| 54 |
* @throws \Exception |
| 55 |
*/ |
| 56 |
public function init() { |
| 57 |
self::$route = graphql_get_endpoint(); |
| 58 |
|
| 59 |
/** |
| 60 |
* Create the rewrite rule for the route |
| 61 |
* |
| 62 |
* @since 0.0.1 |
| 63 |
*/ |
| 64 |
add_action( 'init', [ $this, 'add_rewrite_rule' ], 10 ); |
| 65 |
|
| 66 |
/** |
| 67 |
* Add the query var for the route |
| 68 |
* |
| 69 |
* @since 0.0.1 |
| 70 |
*/ |
| 71 |
add_filter( 'query_vars', [ $this, 'add_query_var' ], 1, 1 ); |
| 72 |
|
| 73 |
/** |
| 74 |
* Redirects the route to the graphql processor |
| 75 |
* |
| 76 |
* @since 0.0.1 |
| 77 |
*/ |
| 78 |
add_action( 'parse_request', [ $this, 'resolve_http_request' ], 10 ); |
| 79 |
|
| 80 |
/** |
| 81 |
* Adds support for application passwords |
| 82 |
*/ |
| 83 |
add_filter( 'application_password_is_api_request', [ $this, 'is_api_request' ] ); |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* Returns the GraphQL Request being executed |
| 88 |
*/ |
| 89 |
public static function get_request(): ?Request { |
| 90 |
return self::$request; |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Adds rewrite rule for the route endpoint |
| 95 |
* |
| 96 |
* @return void |
| 97 |
* @since 0.0.1 |
| 98 |
* @uses add_rewrite_rule() |
| 99 |
*/ |
| 100 |
public static function add_rewrite_rule() { |
| 101 |
add_rewrite_rule( |
| 102 |
self::$route . '/?$', |
| 103 |
'index.php?' . self::$route . '=true', |
| 104 |
'top' |
| 105 |
); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Determines whether the request is an API request to play nice with |
| 110 |
* application passwords and potential other WordPress core functionality |
| 111 |
* for APIs |
| 112 |
* |
| 113 |
* @param bool $is_api_request Whether the request is an API request |
| 114 |
* |
| 115 |
* @return bool |
| 116 |
*/ |
| 117 |
public function is_api_request( $is_api_request ) { |
| 118 |
return true === is_graphql_http_request() ? true : $is_api_request; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Adds the query_var for the route |
| 123 |
* |
| 124 |
* @param string[] $query_vars The array of whitelisted query variables. |
| 125 |
* |
| 126 |
* @return string[] |
| 127 |
* @since 0.0.1 |
| 128 |
*/ |
| 129 |
public static function add_query_var( $query_vars ) { |
| 130 |
$query_vars[] = self::$route; |
| 131 |
|
| 132 |
return $query_vars; |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Returns true when the current request is a GraphQL request coming from the HTTP |
| 137 |
* |
| 138 |
* NOTE: This will only indicate whether the GraphQL Request is an HTTP request. Many features |
| 139 |
* need to affect _all_ GraphQL requests, including internal requests using the `graphql()` |
| 140 |
* function, so be careful how you use this to check your conditions. |
| 141 |
* |
| 142 |
* @return bool |
| 143 |
*/ |
| 144 |
public static function is_graphql_http_request() { |
| 145 |
|
| 146 |
/** |
| 147 |
* Filter whether the request is a GraphQL HTTP Request. Default is null, as the majority |
| 148 |
* of WordPress requests are NOT GraphQL requests (at least today that's true 😆). |
| 149 |
* |
| 150 |
* If this filter returns anything other than null, the function will return now and skip the |
| 151 |
* default checks. |
| 152 |
* |
| 153 |
* @param ?bool $is_graphql_http_request Whether the request is a GraphQL HTTP Request. Default false. |
| 154 |
* @hookGroup request-lifecycle |
| 155 |
* @since 0.0.5 |
| 156 |
*/ |
| 157 |
$pre_is_graphql_http_request = apply_filters( 'graphql_pre_is_graphql_http_request', null ); |
| 158 |
|
| 159 |
/** |
| 160 |
* If the filter has been applied, return now before executing default checks |
| 161 |
*/ |
| 162 |
if ( null !== $pre_is_graphql_http_request ) { |
| 163 |
return (bool) $pre_is_graphql_http_request; |
| 164 |
} |
| 165 |
|
| 166 |
// Default is false |
| 167 |
$is_graphql_http_request = false; |
| 168 |
|
| 169 |
// Support wp-graphiql style request to /index.php?graphql. |
| 170 |
if ( isset( $_GET[ self::$route ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification |
| 171 |
|
| 172 |
$is_graphql_http_request = true; |
| 173 |
} elseif ( isset( $_SERVER['HTTP_HOST'] ) && isset( $_SERVER['REQUEST_URI'] ) ) { |
| 174 |
// Check the server to determine if the GraphQL endpoint is being requested |
| 175 |
$host = wp_unslash( $_SERVER['HTTP_HOST'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 176 |
$uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 177 |
|
| 178 |
if ( ! is_string( $host ) ) { |
| 179 |
return false; |
| 180 |
} |
| 181 |
|
| 182 |
if ( ! is_string( $uri ) ) { |
| 183 |
return false; |
| 184 |
} |
| 185 |
|
| 186 |
$parsed_site_url = wp_parse_url( site_url( self::$route ), PHP_URL_PATH ); |
| 187 |
$graphql_url = ! empty( $parsed_site_url ) ? wp_unslash( $parsed_site_url ) : self::$route; |
| 188 |
$parsed_request_url = wp_parse_url( $uri, PHP_URL_PATH ); |
| 189 |
$request_url = ! empty( $parsed_request_url ) ? wp_unslash( $parsed_request_url ) : ''; |
| 190 |
|
| 191 |
// Determine if the route is indeed a graphql request |
| 192 |
$is_graphql_http_request = str_replace( '/', '', $request_url ) === str_replace( '/', '', $graphql_url ); |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Filter whether the request is a GraphQL HTTP Request. Default is false, as the majority |
| 197 |
* of WordPress requests are NOT GraphQL requests (at least today that's true 😆). |
| 198 |
* |
| 199 |
* The request has to "prove" that it is indeed an HTTP request via HTTP for |
| 200 |
* this to be true. |
| 201 |
* |
| 202 |
* Different servers _might_ have different needs to determine whether a request |
| 203 |
* is a GraphQL request. |
| 204 |
* |
| 205 |
* @param bool $is_graphql_http_request Whether the request is a GraphQL HTTP Request. Default false. |
| 206 |
* @hookGroup request-lifecycle |
| 207 |
* @since 0.0.5 |
| 208 |
*/ |
| 209 |
return apply_filters( 'graphql_is_graphql_http_request', $is_graphql_http_request ); |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* This resolves the http request and ensures that WordPress can respond with the appropriate |
| 214 |
* JSON response instead of responding with a template from the standard WordPress Template |
| 215 |
* Loading process |
| 216 |
* |
| 217 |
* @return void |
| 218 |
* @throws \Exception Throws exception. |
| 219 |
* @throws \Throwable Throws exception. |
| 220 |
* @since 0.0.1 |
| 221 |
*/ |
| 222 |
public static function resolve_http_request() { |
| 223 |
|
| 224 |
/** |
| 225 |
* Access the $wp_query object |
| 226 |
*/ |
| 227 |
global $wp_query; |
| 228 |
|
| 229 |
/** |
| 230 |
* Ensure we're on the registered route for graphql route |
| 231 |
*/ |
| 232 |
if ( ! self::is_graphql_http_request() || is_graphql_request() ) { |
| 233 |
return; |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Set is_home to false |
| 238 |
*/ |
| 239 |
$wp_query->is_home = false; |
| 240 |
|
| 241 |
/** |
| 242 |
* Whether it's a GraphQL HTTP Request |
| 243 |
* |
| 244 |
* @since 0.0.5 |
| 245 |
*/ |
| 246 |
if ( ! defined( 'GRAPHQL_HTTP_REQUEST' ) ) { |
| 247 |
define( 'GRAPHQL_HTTP_REQUEST', true ); |
| 248 |
} |
| 249 |
|
| 250 |
/** |
| 251 |
* Process the GraphQL query Request |
| 252 |
*/ |
| 253 |
self::process_http_request(); |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* Sends an HTTP header. |
| 258 |
* |
| 259 |
* @param string $key Header key. |
| 260 |
* @param string $value Header value. |
| 261 |
* |
| 262 |
* @return void |
| 263 |
* @since 0.0.5 |
| 264 |
*/ |
| 265 |
public static function send_header( $key, $value ) { |
| 266 |
|
| 267 |
/** |
| 268 |
* Sanitize as per RFC2616 (Section 4.2): |
| 269 |
* |
| 270 |
* Any LWS that occurs between field-content MAY be replaced with a |
| 271 |
* single SP before interpreting the field value or forwarding the |
| 272 |
* message downstream. |
| 273 |
*/ |
| 274 |
$value = preg_replace( '/\s+/', ' ', $value ); |
| 275 |
header( apply_filters( 'graphql_send_header', sprintf( '%s: %s', $key, $value ), $key, $value ) ); |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Sends an HTTP status code. |
| 280 |
* |
| 281 |
* @param int|null $status_code The status code to send. |
| 282 |
* |
| 283 |
* @return void |
| 284 |
*/ |
| 285 |
protected static function set_status( ?int $status_code = null ) { |
| 286 |
$status_code = null === $status_code ? self::$http_status_code : $status_code; |
| 287 |
|
| 288 |
// validate that the status code is a valid http status code |
| 289 |
if ( ! is_numeric( $status_code ) || $status_code < 100 || $status_code > 599 ) { |
| 290 |
$status_code = 500; |
| 291 |
} |
| 292 |
|
| 293 |
status_header( $status_code ); |
| 294 |
} |
| 295 |
|
| 296 |
/** |
| 297 |
* Returns an array of headers to send with the HTTP response |
| 298 |
* |
| 299 |
* @return array<string,string> |
| 300 |
*/ |
| 301 |
protected static function get_response_headers() { |
| 302 |
|
| 303 |
/** |
| 304 |
* Filtered list of access control headers. |
| 305 |
* |
| 306 |
* @param string[] $access_control_headers Array of headers to allow. |
| 307 |
* @hookGroup request-lifecycle |
| 308 |
* @since 0.0.5 |
| 309 |
*/ |
| 310 |
$access_control_allow_headers = apply_filters( |
| 311 |
'graphql_access_control_allow_headers', |
| 312 |
[ |
| 313 |
'Authorization', |
| 314 |
'Content-Type', |
| 315 |
// Allows cross-origin clients to send request-level preview context via the |
| 316 |
// `X-GraphQL-Preview` header, the primary preview transport (`extensions.preview` |
| 317 |
// in the request body is the fallback). See WPGraphQL\Request::get_preview_input(). |
| 318 |
'X-GraphQL-Preview', |
| 319 |
] |
| 320 |
); |
| 321 |
|
| 322 |
// For cache url header, use the domain without protocol. Path for when it's multisite. |
| 323 |
// Remove the starting http://, https://, :// from the full hostname/path. |
| 324 |
$host_and_path = preg_replace( '#^.*?://#', '', graphql_get_endpoint_url() ); |
| 325 |
|
| 326 |
$headers = [ |
| 327 |
'Access-Control-Allow-Origin' => '*', |
| 328 |
'Access-Control-Allow-Headers' => implode( ', ', $access_control_allow_headers ), |
| 329 |
'Access-Control-Max-Age' => '600', // cache the result of preflight requests (600 is the upper limit for Chromium). |
| 330 |
'Content-Type' => 'application/json ; charset=' . get_option( 'blog_charset' ), |
| 331 |
'X-Robots-Tag' => 'noindex', |
| 332 |
'X-Content-Type-Options' => 'nosniff', |
| 333 |
'X-GraphQL-URL' => (string) $host_and_path, |
| 334 |
]; |
| 335 |
|
| 336 |
// If the Query Analyzer was instantiated |
| 337 |
// Get the headers determined from its Analysis |
| 338 |
if ( self::get_request() instanceof Request && self::get_request()->get_query_analyzer()->is_enabled_for_query() ) { |
| 339 |
$headers = self::get_request()->get_query_analyzer()->get_headers( $headers ); |
| 340 |
} |
| 341 |
|
| 342 |
if ( true === \WPGraphQL::debug() ) { |
| 343 |
$headers['X-hacker'] = __( 'If you\'re reading this, you should visit github.com/wp-graphql/wp-graphql and contribute!', 'wp-graphql' ); |
| 344 |
} |
| 345 |
|
| 346 |
$request = self::get_request(); |
| 347 |
$is_authenticated = $request instanceof Request |
| 348 |
&& $request->app_context->viewer instanceof WP_User |
| 349 |
&& $request->app_context->viewer->exists(); |
| 350 |
if ( ! $is_authenticated ) { |
| 351 |
$is_authenticated = is_user_logged_in(); |
| 352 |
} |
| 353 |
/** |
| 354 |
* Filters whether no-cache headers should be sent on the GraphQL HTTP response. |
| 355 |
* |
| 356 |
* Prefer the current request's viewer when available (after execution) so we |
| 357 |
* send no-cache for the request that was actually authenticated, regardless |
| 358 |
* of global user timing. Fall back to is_user_logged_in() for paths that |
| 359 |
* run before the Request exists (e.g. 403 auth error, OPTIONS). |
| 360 |
* |
| 361 |
* @see https://github.com/wp-graphql/wp-graphql/issues/3340 |
| 362 |
* |
| 363 |
* @param bool $send_no_cache_headers Whether to send no-cache headers. |
| 364 |
* @hookGroup request-lifecycle |
| 365 |
* @since 0.0.5 |
| 366 |
*/ |
| 367 |
$send_no_cache_headers = apply_filters( 'graphql_send_nocache_headers', $is_authenticated ); |
| 368 |
if ( $send_no_cache_headers ) { |
| 369 |
foreach ( wp_get_nocache_headers() as $no_cache_header_key => $no_cache_header_value ) { |
| 370 |
$headers[ $no_cache_header_key ] = $no_cache_header_value; |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* Responses vary on the preview context header: a cache that keys responses |
| 376 |
* without it could otherwise serve a previewed response in place of the |
| 377 |
* published one, or vice versa. Sent unconditionally so caches learn the axis |
| 378 |
* before ever storing a response. |
| 379 |
*/ |
| 380 |
$headers['Vary'] = isset( $headers['Vary'] ) && '' !== $headers['Vary'] |
| 381 |
? $headers['Vary'] . ', X-GraphQL-Preview' |
| 382 |
: 'X-GraphQL-Preview'; |
| 383 |
|
| 384 |
/** |
| 385 |
* A request carrying preview context may expose draft content to the authorized |
| 386 |
* viewer, so no cache may store the response at all. This is deliberately |
| 387 |
* stronger than the authenticated no-cache headers above: `no-cache` only |
| 388 |
* requires revalidation before reuse, while `no-store` forbids storage. |
| 389 |
*/ |
| 390 |
if ( $request instanceof Request && is_array( $request->app_context->preview ) ) { |
| 391 |
$headers['Cache-Control'] = 'no-store, private'; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Filter the $headers to send |
| 396 |
* |
| 397 |
* @param array<string,string> $headers The headers to send |
| 398 |
* @hookGroup request-lifecycle |
| 399 |
* @since 0.0.5 |
| 400 |
*/ |
| 401 |
$headers = apply_filters( 'graphql_response_headers_to_send', $headers ); |
| 402 |
|
| 403 |
return is_array( $headers ) ? $headers : []; |
| 404 |
} |
| 405 |
|
| 406 |
/** |
| 407 |
* Set the response headers |
| 408 |
* |
| 409 |
* @return void |
| 410 |
* @since 0.0.1 |
| 411 |
*/ |
| 412 |
public static function set_headers() { |
| 413 |
if ( false === headers_sent() ) { |
| 414 |
|
| 415 |
/** |
| 416 |
* Set the HTTP response status |
| 417 |
*/ |
| 418 |
self::set_status( self::$http_status_code ); |
| 419 |
|
| 420 |
/** |
| 421 |
* Get the response headers |
| 422 |
*/ |
| 423 |
$headers = self::get_response_headers(); |
| 424 |
|
| 425 |
/** |
| 426 |
* If there are headers, set them for the response |
| 427 |
*/ |
| 428 |
if ( ! empty( $headers ) && is_array( $headers ) ) { |
| 429 |
foreach ( $headers as $key => $value ) { |
| 430 |
self::send_header( $key, $value ); |
| 431 |
} |
| 432 |
} |
| 433 |
|
| 434 |
/** |
| 435 |
* Fire an action when the headers are set |
| 436 |
* |
| 437 |
* @param array<string,string> $headers The headers sent in the response |
| 438 |
* @hookGroup request-lifecycle |
| 439 |
* @since 0.0.5 |
| 440 |
*/ |
| 441 |
do_action( 'graphql_response_set_headers', $headers ); |
| 442 |
} |
| 443 |
} |
| 444 |
|
| 445 |
/** |
| 446 |
* Retrieves the raw request entity (body). |
| 447 |
* |
| 448 |
* @since 0.0.5 |
| 449 |
* |
| 450 |
* @global string php://input Raw post data. |
| 451 |
* |
| 452 |
* @return string Raw request data. |
| 453 |
*/ |
| 454 |
public static function get_raw_data() { |
| 455 |
$input = file_get_contents( 'php://input' ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsRemoteFile |
| 456 |
|
| 457 |
return ! empty( $input ) ? $input : ''; |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* This processes the graphql requests that come into the /graphql endpoint via an HTTP request |
| 462 |
* |
| 463 |
* @return void |
| 464 |
* @throws \Throwable Throws Exception. |
| 465 |
* @global WP_User $current_user The currently authenticated user. |
| 466 |
* @since 0.0.1 |
| 467 |
*/ |
| 468 |
public static function process_http_request() { |
| 469 |
global $current_user; |
| 470 |
|
| 471 |
if ( $current_user instanceof WP_User && ! $current_user->exists() ) { |
| 472 |
/* |
| 473 |
* If there is no current user authenticated via other means, clear |
| 474 |
* the cached lack of user, so that an authenticate check can set it |
| 475 |
* properly. |
| 476 |
* |
| 477 |
* This is done because for authentications such as Application |
| 478 |
* Passwords, we don't want it to be accepted unless the current HTTP |
| 479 |
* request is a GraphQL API request, which can't always be identified early |
| 480 |
* enough in evaluation. |
| 481 |
* |
| 482 |
* See serve_request in wp-includes/rest-api/class-wp-rest-server.php. |
| 483 |
*/ |
| 484 |
$current_user = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* Validate authentication BEFORE any GraphQL hooks fire. |
| 489 |
* |
| 490 |
* This is critical for security - we must validate/downgrade authentication |
| 491 |
* before plugins can hook in and potentially expose sensitive information |
| 492 |
* based on the (not-yet-validated) authenticated user. |
| 493 |
* |
| 494 |
* For cookie-authenticated requests: |
| 495 |
* - No nonce: User is downgraded to guest |
| 496 |
* - Invalid nonce: Returns error response immediately |
| 497 |
* - Valid nonce: Proceeds normally |
| 498 |
* |
| 499 |
* @since 2.6.0 |
| 500 |
*/ |
| 501 |
$auth_error = self::validate_http_request_authentication(); |
| 502 |
|
| 503 |
if ( is_wp_error( $auth_error ) ) { |
| 504 |
/** |
| 505 |
* Filter the HTTP status code returned for authentication errors. |
| 506 |
* |
| 507 |
* By default, invalid nonce errors return 403 Forbidden. Some clients |
| 508 |
* may expect 200 with a GraphQL error response instead. |
| 509 |
* |
| 510 |
* @since 2.6.0 |
| 511 |
* |
| 512 |
* @param int $status_code The HTTP status code. Default 403. |
| 513 |
* @param \WP_Error $auth_error The authentication error. |
| 514 |
* @hookGroup authentication |
| 515 |
*/ |
| 516 |
self::$http_status_code = apply_filters( 'graphql_authentication_error_status_code', 403, $auth_error ); |
| 517 |
self::set_headers(); |
| 518 |
wp_send_json( |
| 519 |
[ |
| 520 |
'errors' => [ |
| 521 |
[ |
| 522 |
'message' => $auth_error->get_error_message(), |
| 523 |
], |
| 524 |
], |
| 525 |
] |
| 526 |
); |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* This action can be hooked to to enable various debug tools, |
| 531 |
* such as enableValidation from the GraphQL Config. |
| 532 |
* |
| 533 |
* @hookGroup request-lifecycle |
| 534 |
* @since 0.0.4 |
| 535 |
*/ |
| 536 |
do_action( 'graphql_process_http_request' ); |
| 537 |
|
| 538 |
/** |
| 539 |
* Respond to pre-flight requests. |
| 540 |
* |
| 541 |
* Bail before Request() execution begins. |
| 542 |
* |
| 543 |
* @see: https://apollographql.slack.com/archives/C10HTKHPC/p1507649812000123 |
| 544 |
* @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests |
| 545 |
*/ |
| 546 |
if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) { |
| 547 |
self::$http_status_code = 200; |
| 548 |
self::set_headers(); |
| 549 |
exit; |
| 550 |
} |
| 551 |
|
| 552 |
$response = []; |
| 553 |
$query = ''; |
| 554 |
$operation_name = ''; |
| 555 |
$variables = []; |
| 556 |
self::$request = new Request(); |
| 557 |
|
| 558 |
try { |
| 559 |
// Start output buffering to prevent any unwanted output from breaking the JSON response |
| 560 |
// This addresses issues like plugins calling wp_print_inline_script_tag() during wp_enqueue_scripts |
| 561 |
ob_start(); |
| 562 |
|
| 563 |
$response = self::$request->execute_http(); |
| 564 |
|
| 565 |
// Discard any captured output that could break the JSON response |
| 566 |
ob_end_clean(); |
| 567 |
|
| 568 |
// Get the operation params from the request. |
| 569 |
$params = self::$request->get_params(); |
| 570 |
$query = isset( $params->query ) ? $params->query : ''; |
| 571 |
$operation_name = isset( $params->operation ) ? $params->operation : ''; |
| 572 |
$variables = isset( $params->variables ) ? $params->variables : null; |
| 573 |
} catch ( \Throwable $error ) { |
| 574 |
// Make sure to clean up the output buffer even if there's an exception |
| 575 |
if ( ob_get_level() > 0 ) { |
| 576 |
ob_end_clean(); |
| 577 |
} |
| 578 |
|
| 579 |
/** |
| 580 |
* If there are errors, set the status to 500 |
| 581 |
* and format the captured errors to be output properly |
| 582 |
* |
| 583 |
* @since 0.0.4 |
| 584 |
*/ |
| 585 |
self::$http_status_code = 500; |
| 586 |
|
| 587 |
/** |
| 588 |
* Filter thrown GraphQL errors |
| 589 |
* |
| 590 |
* @var SerializableResult $response |
| 591 |
* |
| 592 |
* @param SerializableError[] $errors The errors array to be sent in the response. |
| 593 |
* @param \Throwable $error Thrown error object. |
| 594 |
* @param \WPGraphQL\Request $request WPGraphQL Request object. |
| 595 |
* @hookGroup request-lifecycle |
| 596 |
* @since 0.0.4 |
| 597 |
*/ |
| 598 |
$response['errors'] = apply_filters( |
| 599 |
'graphql_http_request_response_errors', |
| 600 |
[ FormattedError::createFromException( $error, self::$request->get_debug_flag() ) ], |
| 601 |
$error, |
| 602 |
self::$request |
| 603 |
); |
| 604 |
} |
| 605 |
|
| 606 |
// Previously there was a small distinction between the response and the result, but |
| 607 |
// now that we are delegating to Request, just send the response for both. |
| 608 |
|
| 609 |
if ( false === headers_sent() ) { |
| 610 |
self::prepare_headers( $response, $response, $query, $operation_name, $variables ); |
| 611 |
} |
| 612 |
|
| 613 |
/** |
| 614 |
* Run an action after the HTTP Response is ready to be sent back. This might be a good place for tools |
| 615 |
* to hook in to track metrics, such as how long the process took from `graphql_process_http_request` |
| 616 |
* to here, etc. |
| 617 |
* |
| 618 |
* @param WPGraphQLResult $response The GraphQL response |
| 619 |
* @param WPGraphQLResult $result Deprecated. Same as $response. |
| 620 |
* @param string $operation_name The name of the operation |
| 621 |
* @param string $query The request that GraphQL executed |
| 622 |
* @param ?array<string,mixed> $variables Variables to passed to your GraphQL query |
| 623 |
* @param int|string $status_code The status code for the response |
| 624 |
* |
| 625 |
* @hookGroup request-lifecycle |
| 626 |
* @since 0.0.5 |
| 627 |
*/ |
| 628 |
do_action( 'graphql_process_http_request_response', $response, $response, $operation_name, $query, $variables, self::$http_status_code ); |
| 629 |
|
| 630 |
/** |
| 631 |
* Send the response |
| 632 |
*/ |
| 633 |
wp_send_json( $response ); |
| 634 |
} |
| 635 |
|
| 636 |
/** |
| 637 |
* Prepare headers for response |
| 638 |
* |
| 639 |
* @param mixed[]|\GraphQL\Executor\ExecutionResult $response The response of the GraphQL Request. |
| 640 |
* @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated Deprecated. |
| 641 |
* @param string $query The GraphQL query. |
| 642 |
* @param string $operation_name The operation name of the GraphQL Request. |
| 643 |
* @param ?array<string,mixed> $variables The variables applied to the GraphQL Request. |
| 644 |
* @param ?\WP_User $user The current user object. |
| 645 |
* |
| 646 |
* @return void |
| 647 |
*/ |
| 648 |
protected static function prepare_headers( $response, $_deprecated, string $query, string $operation_name, $variables, $user = null ) { |
| 649 |
|
| 650 |
/** |
| 651 |
* Filter the $status_code before setting the headers |
| 652 |
* |
| 653 |
* @param int $status_code The status code to apply to the headers |
| 654 |
* @param mixed[]|\GraphQL\Executor\ExecutionResult $response The response of the GraphQL Request |
| 655 |
* @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated Use $response instead. |
| 656 |
* @param string $query The GraphQL query |
| 657 |
* @param string $operation_name The operation name of the GraphQL Request |
| 658 |
* @param ?array<string,mixed> $variables The variables applied to the GraphQL Request |
| 659 |
* @param ?\WP_User $user The current user object |
| 660 |
* @hookGroup request-lifecycle |
| 661 |
* @since 0.0.5 |
| 662 |
*/ |
| 663 |
self::$http_status_code = apply_filters( 'graphql_response_status_code', self::$http_status_code, $_deprecated, $response, $query, $operation_name, $variables, $user ); |
| 664 |
|
| 665 |
/** |
| 666 |
* Set the response headers |
| 667 |
*/ |
| 668 |
self::set_headers(); |
| 669 |
} |
| 670 |
|
| 671 |
/** |
| 672 |
* @deprecated 0.4.1 Use Router::is_graphql_http_request instead. This now resolves to it |
| 673 |
* @todo remove in v3.0 |
| 674 |
* @codeCoverageIgnore |
| 675 |
* |
| 676 |
* @return bool |
| 677 |
*/ |
| 678 |
public static function is_graphql_request() { |
| 679 |
_doing_it_wrong( |
| 680 |
__METHOD__, |
| 681 |
sprintf( |
| 682 |
/* translators: %s is the class name */ |
| 683 |
esc_html__( 'This method is deprecated and will be removed in the next major version of WPGraphQL. Use %s instead.', 'wp-graphql' ), |
| 684 |
esc_html( self::class . '::is_graphql_http_request()' ) |
| 685 |
), |
| 686 |
'0.4.1' |
| 687 |
); |
| 688 |
return self::is_graphql_http_request(); |
| 689 |
} |
| 690 |
|
| 691 |
/** |
| 692 |
* Validates HTTP request authentication BEFORE any GraphQL processing begins. |
| 693 |
* |
| 694 |
* This method provides CSRF protection for cookie-authenticated requests. |
| 695 |
* It runs before `graphql_process_http_request` and other hooks fire, ensuring |
| 696 |
* plugins cannot inadvertently expose sensitive data based on a user identity |
| 697 |
* that hasn't been validated yet. |
| 698 |
* |
| 699 |
* For cookie-authenticated requests: |
| 700 |
* - No nonce provided: User is downgraded to guest (CSRF protection) |
| 701 |
* - Invalid nonce: Returns WP_Error (caller should return error response) |
| 702 |
* - Valid nonce: Returns null (authentication preserved) |
| 703 |
* |
| 704 |
* @since 2.6.0 |
| 705 |
* |
| 706 |
* @return \WP_Error|null WP_Error if invalid nonce, null otherwise. |
| 707 |
*/ |
| 708 |
public static function validate_http_request_authentication(): ?\WP_Error { |
| 709 |
/** |
| 710 |
* Only validate for logged-in users. |
| 711 |
* Guest users don't need validation - they're already unauthenticated. |
| 712 |
*/ |
| 713 |
if ( ! is_user_logged_in() ) { |
| 714 |
return null; |
| 715 |
} |
| 716 |
|
| 717 |
/** |
| 718 |
* Check if an Authorization header is present. |
| 719 |
* If so, this is likely a non-cookie auth method (JWT, Application Passwords, etc.) |
| 720 |
* which are inherently CSRF-safe and don't need nonce validation. |
| 721 |
*/ |
| 722 |
$has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) |
| 723 |
|| ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); |
| 724 |
|
| 725 |
if ( $has_auth_header ) { |
| 726 |
return null; |
| 727 |
} |
| 728 |
|
| 729 |
/** |
| 730 |
* No Authorization header = cookie-based authentication. |
| 731 |
* Check for nonce in request param or header. |
| 732 |
*/ |
| 733 |
$nonce = null; |
| 734 |
|
| 735 |
if ( isset( $_REQUEST['_wpnonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 736 |
$nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 737 |
} elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) { |
| 738 |
$nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 739 |
} |
| 740 |
|
| 741 |
/** |
| 742 |
* Treat "falsy" nonce values as "no nonce provided". |
| 743 |
* This handles JavaScript serialization edge cases where null/undefined |
| 744 |
* get converted to strings. |
| 745 |
*/ |
| 746 |
$empty_nonce_values = [ '', 'null', 'undefined', 'false', '0' ]; |
| 747 |
if ( in_array( $nonce, $empty_nonce_values, true ) ) { |
| 748 |
$nonce = null; |
| 749 |
} |
| 750 |
|
| 751 |
/** |
| 752 |
* Filter whether to require a nonce for cookie-based authentication. |
| 753 |
* |
| 754 |
* By default, WPGraphQL requires a nonce (X-WP-Nonce header or _wpnonce parameter) |
| 755 |
* for cookie-authenticated requests to prevent CSRF attacks. |
| 756 |
* |
| 757 |
* @since 2.5.4 |
| 758 |
* |
| 759 |
* @param bool $require_nonce Whether to require a nonce for cookie auth. Default true. |
| 760 |
* @param null $request The Request instance (null in Router context). |
| 761 |
* @hookGroup authentication |
| 762 |
*/ |
| 763 |
$require_nonce = apply_filters( 'graphql_cookie_auth_require_nonce', true, null ); |
| 764 |
|
| 765 |
/** |
| 766 |
* If nonce is not required, allow the authenticated request. |
| 767 |
*/ |
| 768 |
if ( ! $require_nonce ) { |
| 769 |
return null; |
| 770 |
} |
| 771 |
|
| 772 |
/** |
| 773 |
* No nonce provided - downgrade to guest (unless plugin prevents it). |
| 774 |
*/ |
| 775 |
if ( null === $nonce ) { |
| 776 |
/** |
| 777 |
* Allow plugins to prevent the downgrade via the graphql_authentication_errors filter. |
| 778 |
* |
| 779 |
* @param bool|null $authentication_errors Null to allow default behavior, false to preserve auth. |
| 780 |
* @param \WPGraphQL\Request|null $request The Request instance (null in Router context). |
| 781 |
* @hookGroup authentication |
| 782 |
* @since 0.0.5 |
| 783 |
*/ |
| 784 |
$filtered = apply_filters( 'graphql_authentication_errors', null, self::get_request() ); |
| 785 |
|
| 786 |
// If a plugin explicitly returned false (no errors), preserve authentication |
| 787 |
if ( false === $filtered ) { |
| 788 |
return null; |
| 789 |
} |
| 790 |
|
| 791 |
// Downgrade to guest |
| 792 |
wp_set_current_user( 0 ); |
| 793 |
return null; |
| 794 |
} |
| 795 |
|
| 796 |
/** |
| 797 |
* Nonce provided - validate it. |
| 798 |
* Support both 'wp_graphql' and 'wp_rest' for backward compatibility. |
| 799 |
*/ |
| 800 |
$nonce_valid = wp_verify_nonce( $nonce, 'wp_graphql' ) || wp_verify_nonce( $nonce, 'wp_rest' ); |
| 801 |
|
| 802 |
if ( ! $nonce_valid ) { |
| 803 |
return new \WP_Error( |
| 804 |
'graphql_cookie_invalid_nonce', |
| 805 |
__( 'Cookie nonce is invalid', 'wp-graphql' ), |
| 806 |
[ 'status' => 403 ] |
| 807 |
); |
| 808 |
} |
| 809 |
|
| 810 |
return null; |
| 811 |
} |
| 812 |
} |
| 813 |
|