PluginProbe
WPGraphQL / 2.22.2
WPGraphQL v2.22.2
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Router.php

Router.php in WPGraphQL 2.22.2, at src/Router.php

813 lines 25.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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