• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql / 20311247040

17 Dec 2025 05:15PM UTC coverage: 83.642% (+0.02%) from 83.619%
20311247040

push

github

actions-user
release: merge develop into master for v2.5.4

16296 of 19483 relevant lines covered (83.64%)

264.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

33.33
/src/Router.php
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 {
4✔
90
                return self::$request;
4✔
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 ) {
87✔
130
                $query_vars[] = self::$route;
87✔
131

132
                return $query_vars;
87✔
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() {
797✔
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
                 */
155
                $pre_is_graphql_http_request = apply_filters( 'graphql_pre_is_graphql_http_request', null );
797✔
156

157
                /**
158
                 * If the filter has been applied, return now before executing default checks
159
                 */
160
                if ( null !== $pre_is_graphql_http_request ) {
797✔
161
                        return (bool) $pre_is_graphql_http_request;
8✔
162
                }
163

164
                // Default is false
165
                $is_graphql_http_request = false;
789✔
166

167
                // Support wp-graphiql style request to /index.php?graphql.
168
                if ( isset( $_GET[ self::$route ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
789✔
169

170
                        $is_graphql_http_request = true;
1✔
171
                } elseif ( isset( $_SERVER['HTTP_HOST'] ) && isset( $_SERVER['REQUEST_URI'] ) ) {
788✔
172
                        // Check the server to determine if the GraphQL endpoint is being requested
173
                        $host = wp_unslash( $_SERVER['HTTP_HOST'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
788✔
174
                        $uri  = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
788✔
175

176
                        if ( ! is_string( $host ) ) {
788✔
177
                                return false;
×
178
                        }
179

180
                        if ( ! is_string( $uri ) ) {
788✔
181
                                return false;
×
182
                        }
183

184
                        $parsed_site_url    = wp_parse_url( site_url( self::$route ), PHP_URL_PATH );
788✔
185
                        $graphql_url        = ! empty( $parsed_site_url ) ? wp_unslash( $parsed_site_url ) : self::$route;
788✔
186
                        $parsed_request_url = wp_parse_url( $uri, PHP_URL_PATH );
788✔
187
                        $request_url        = ! empty( $parsed_request_url ) ? wp_unslash( $parsed_request_url ) : '';
788✔
188

189
                        // Determine if the route is indeed a graphql request
190
                        $is_graphql_http_request = str_replace( '/', '', $request_url ) === str_replace( '/', '', $graphql_url );
788✔
191
                }
192

193
                /**
194
                 * Filter whether the request is a GraphQL HTTP Request. Default is false, as the majority
195
                 * of WordPress requests are NOT GraphQL requests (at least today that's true 😆).
196
                 *
197
                 * The request has to "prove" that it is indeed an HTTP request via HTTP for
198
                 * this to be true.
199
                 *
200
                 * Different servers _might_ have different needs to determine whether a request
201
                 * is a GraphQL request.
202
                 *
203
                 * @param bool $is_graphql_http_request Whether the request is a GraphQL HTTP Request. Default false.
204
                 */
205
                return apply_filters( 'graphql_is_graphql_http_request', $is_graphql_http_request );
789✔
206
        }
207

208
        /**
209
         * This resolves the http request and ensures that WordPress can respond with the appropriate
210
         * JSON response instead of responding with a template from the standard WordPress Template
211
         * Loading process
212
         *
213
         * @return void
214
         * @throws \Exception Throws exception.
215
         * @throws \Throwable Throws exception.
216
         * @since  0.0.1
217
         */
218
        public static function resolve_http_request() {
86✔
219

220
                /**
221
                 * Access the $wp_query object
222
                 */
223
                global $wp_query;
86✔
224

225
                /**
226
                 * Ensure we're on the registered route for graphql route
227
                 */
228
                if ( ! self::is_graphql_http_request() || is_graphql_request() ) {
86✔
229
                        return;
86✔
230
                }
231

232
                /**
233
                 * Set is_home to false
234
                 */
235
                $wp_query->is_home = false;
×
236

237
                /**
238
                 * Whether it's a GraphQL HTTP Request
239
                 *
240
                 * @since 0.0.5
241
                 */
242
                if ( ! defined( 'GRAPHQL_HTTP_REQUEST' ) ) {
×
243
                        define( 'GRAPHQL_HTTP_REQUEST', true );
×
244
                }
245

246
                /**
247
                 * Process the GraphQL query Request
248
                 */
249
                self::process_http_request();
×
250
        }
251

252
        /**
253
         * Sends an HTTP header.
254
         *
255
         * @param string $key   Header key.
256
         * @param string $value Header value.
257
         *
258
         * @return void
259
         * @since  0.0.5
260
         */
261
        public static function send_header( $key, $value ) {
×
262

263
                /**
264
                 * Sanitize as per RFC2616 (Section 4.2):
265
                 *
266
                 * Any LWS that occurs between field-content MAY be replaced with a
267
                 * single SP before interpreting the field value or forwarding the
268
                 * message downstream.
269
                 */
270
                $value = preg_replace( '/\s+/', ' ', $value );
×
271
                header( apply_filters( 'graphql_send_header', sprintf( '%s: %s', $key, $value ), $key, $value ) );
×
272
        }
273

274
        /**
275
         * Sends an HTTP status code.
276
         *
277
         * @param int|null $status_code The status code to send.
278
         *
279
         * @return void
280
         */
281
        protected static function set_status( ?int $status_code = null ) {
×
282
                $status_code = null === $status_code ? self::$http_status_code : $status_code;
×
283

284
                // validate that the status code is a valid http status code
285
                if ( ! is_numeric( $status_code ) || $status_code < 100 || $status_code > 599 ) {
×
286
                        $status_code = 500;
×
287
                }
288

289
                status_header( $status_code );
×
290
        }
291

292
        /**
293
         * Returns an array of headers to send with the HTTP response
294
         *
295
         * @return array<string,string>
296
         */
297
        protected static function get_response_headers() {
×
298

299
                /**
300
                 * Filtered list of access control headers.
301
                 *
302
                 * @param string[] $access_control_headers Array of headers to allow.
303
                 */
304
                $access_control_allow_headers = apply_filters(
×
305
                        'graphql_access_control_allow_headers',
×
306
                        [
×
307
                                'Authorization',
×
308
                                'Content-Type',
×
309
                        ]
×
310
                );
×
311

312
                // For cache url header, use the domain without protocol. Path for when it's multisite.
313
                // Remove the starting http://, https://, :// from the full hostname/path.
314
                $host_and_path = preg_replace( '#^.*?://#', '', graphql_get_endpoint_url() );
×
315

316
                $headers = [
×
317
                        'Access-Control-Allow-Origin'  => '*',
×
318
                        'Access-Control-Allow-Headers' => implode( ', ', $access_control_allow_headers ),
×
319
                        'Access-Control-Max-Age'       => '600', // cache the result of preflight requests (600 is the upper limit for Chromium).
×
320
                        'Content-Type'                 => 'application/json ; charset=' . get_option( 'blog_charset' ),
×
321
                        'X-Robots-Tag'                 => 'noindex',
×
322
                        'X-Content-Type-Options'       => 'nosniff',
×
323
                        'X-GraphQL-URL'                => (string) $host_and_path,
×
324
                ];
×
325

326
                // If the Query Analyzer was instantiated
327
                // Get the headers determined from its Analysis
328
                if ( self::get_request() instanceof Request && self::get_request()->get_query_analyzer()->is_enabled_for_query() ) {
×
329
                        $headers = self::get_request()->get_query_analyzer()->get_headers( $headers );
×
330
                }
331

332
                if ( true === \WPGraphQL::debug() ) {
×
333
                        $headers['X-hacker'] = __( 'If you\'re reading this, you should visit github.com/wp-graphql/wp-graphql and contribute!', 'wp-graphql' );
×
334
                }
335

336
                /**
337
                 * Send nocache headers on authenticated requests.
338
                 *
339
                 * @param bool $rest_send_nocache_headers Whether to send no-cache headers.
340
                 *
341
                 * @since 0.0.5
342
                 */
343
                $send_no_cache_headers = apply_filters( 'graphql_send_nocache_headers', is_user_logged_in() );
×
344
                if ( $send_no_cache_headers ) {
×
345
                        foreach ( wp_get_nocache_headers() as $no_cache_header_key => $no_cache_header_value ) {
×
346
                                $headers[ $no_cache_header_key ] = $no_cache_header_value;
×
347
                        }
348
                }
349

350
                /**
351
                 * Filter the $headers to send
352
                 *
353
                 * @param array<string,string> $headers The headers to send
354
                 */
355
                $headers = apply_filters( 'graphql_response_headers_to_send', $headers );
×
356

357
                return is_array( $headers ) ? $headers : [];
×
358
        }
359

360
        /**
361
         * Set the response headers
362
         *
363
         * @return void
364
         * @since  0.0.1
365
         */
366
        public static function set_headers() {
×
367
                if ( false === headers_sent() ) {
×
368

369
                        /**
370
                         * Set the HTTP response status
371
                         */
372
                        self::set_status( self::$http_status_code );
×
373

374
                        /**
375
                         * Get the response headers
376
                         */
377
                        $headers = self::get_response_headers();
×
378

379
                        /**
380
                         * If there are headers, set them for the response
381
                         */
382
                        if ( ! empty( $headers ) && is_array( $headers ) ) {
×
383
                                foreach ( $headers as $key => $value ) {
×
384
                                        self::send_header( $key, $value );
×
385
                                }
386
                        }
387

388
                        /**
389
                         * Fire an action when the headers are set
390
                         *
391
                         * @param array<string,string> $headers The headers sent in the response
392
                         */
393
                        do_action( 'graphql_response_set_headers', $headers );
×
394
                }
395
        }
396

397
        /**
398
         * Retrieves the raw request entity (body).
399
         *
400
         * @since  0.0.5
401
         *
402
         * @global string php://input Raw post data.
403
         *
404
         * @return string Raw request data.
405
         */
406
        public static function get_raw_data() {
2✔
407
                $input = file_get_contents( 'php://input' ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsRemoteFile
2✔
408

409
                return ! empty( $input ) ? $input : '';
2✔
410
        }
411

412
        /**
413
         * This processes the graphql requests that come into the /graphql endpoint via an HTTP request
414
         *
415
         * @return void
416
         * @throws \Throwable Throws Exception.
417
         * @global WP_User $current_user The currently authenticated user.
418
         * @since  0.0.1
419
         */
420
        public static function process_http_request() {
×
421
                global $current_user;
×
422

423
                if ( $current_user instanceof WP_User && ! $current_user->exists() ) {
×
424
                        /*
425
                         * If there is no current user authenticated via other means, clear
426
                         * the cached lack of user, so that an authenticate check can set it
427
                         * properly.
428
                         *
429
                         * This is done because for authentications such as Application
430
                         * Passwords, we don't want it to be accepted unless the current HTTP
431
                         * request is a GraphQL API request, which can't always be identified early
432
                         * enough in evaluation.
433
                         *
434
                         * See serve_request in wp-includes/rest-api/class-wp-rest-server.php.
435
                         */
436
                        $current_user = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
×
437
                }
438

439
                /**
440
                 * Validate authentication BEFORE any GraphQL hooks fire.
441
                 *
442
                 * This is critical for security - we must validate/downgrade authentication
443
                 * before plugins can hook in and potentially expose sensitive information
444
                 * based on the (not-yet-validated) authenticated user.
445
                 *
446
                 * For cookie-authenticated requests:
447
                 * - No nonce: User is downgraded to guest
448
                 * - Invalid nonce: Returns error response immediately
449
                 * - Valid nonce: Proceeds normally
450
                 *
451
                 * @since 2.6.0
452
                 */
453
                $auth_error = self::validate_http_request_authentication();
×
454

455
                if ( is_wp_error( $auth_error ) ) {
×
456
                        /**
457
                         * Filter the HTTP status code returned for authentication errors.
458
                         *
459
                         * By default, invalid nonce errors return 403 Forbidden. Some clients
460
                         * may expect 200 with a GraphQL error response instead.
461
                         *
462
                         * @since 2.6.0
463
                         *
464
                         * @param int       $status_code The HTTP status code. Default 403.
465
                         * @param \WP_Error $auth_error  The authentication error.
466
                         */
467
                        self::$http_status_code = apply_filters( 'graphql_authentication_error_status_code', 403, $auth_error );
×
468
                        self::set_headers();
×
469
                        wp_send_json(
×
470
                                [
×
471
                                        'errors' => [
×
472
                                                [
×
473
                                                        'message' => $auth_error->get_error_message(),
×
474
                                                ],
×
475
                                        ],
×
476
                                ]
×
477
                        );
×
478
                }
479

480
                /**
481
                 * This action can be hooked to to enable various debug tools,
482
                 * such as enableValidation from the GraphQL Config.
483
                 *
484
                 * @since 0.0.4
485
                 */
486
                do_action( 'graphql_process_http_request' );
×
487

488
                /**
489
                 * Respond to pre-flight requests.
490
                 *
491
                 * Bail before Request() execution begins.
492
                 *
493
                 * @see: https://apollographql.slack.com/archives/C10HTKHPC/p1507649812000123
494
                 * @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests
495
                 */
496
                if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
×
497
                        self::$http_status_code = 200;
×
498
                        self::set_headers();
×
499
                        exit;
×
500
                }
501

502
                $response       = [];
×
503
                $query          = '';
×
504
                $operation_name = '';
×
505
                $variables      = [];
×
506
                self::$request  = new Request();
×
507

508
                try {
509
                        // Start output buffering to prevent any unwanted output from breaking the JSON response
510
                        // This addresses issues like plugins calling wp_print_inline_script_tag() during wp_enqueue_scripts
511
                        ob_start();
×
512

513
                        $response = self::$request->execute_http();
×
514

515
                        // Discard any captured output that could break the JSON response
516
                        ob_end_clean();
×
517

518
                        // Get the operation params from the request.
519
                        $params         = self::$request->get_params();
×
520
                        $query          = isset( $params->query ) ? $params->query : '';
×
521
                        $operation_name = isset( $params->operation ) ? $params->operation : '';
×
522
                        $variables      = isset( $params->variables ) ? $params->variables : null;
×
523
                } catch ( \Throwable $error ) {
×
524
                        // Make sure to clean up the output buffer even if there's an exception
525
                        if ( ob_get_level() > 0 ) {
×
526
                                ob_end_clean();
×
527
                        }
528

529
                        /**
530
                         * If there are errors, set the status to 500
531
                         * and format the captured errors to be output properly
532
                         *
533
                         * @since 0.0.4
534
                         */
535
                        self::$http_status_code = 500;
×
536

537
                        /**
538
                         * Filter thrown GraphQL errors
539
                         *
540
                         * @var SerializableResult $response
541
                         *
542
                         * @param SerializableError[] $errors  The errors array to be sent in the response.
543
                         * @param \Throwable          $error   Thrown error object.
544
                         * @param \WPGraphQL\Request  $request WPGraphQL Request object.
545
                         */
546
                        $response['errors'] = apply_filters(
×
547
                                'graphql_http_request_response_errors',
×
548
                                [ FormattedError::createFromException( $error, self::$request->get_debug_flag() ) ],
×
549
                                $error,
×
550
                                self::$request
×
551
                        );
×
552
                }
553

554
                // Previously there was a small distinction between the response and the result, but
555
                // now that we are delegating to Request, just send the response for both.
556

557
                if ( false === headers_sent() ) {
×
558
                        self::prepare_headers( $response, $response, $query, $operation_name, $variables );
×
559
                }
560

561
                /**
562
                 * Run an action after the HTTP Response is ready to be sent back. This might be a good place for tools
563
                 * to hook in to track metrics, such as how long the process took from `graphql_process_http_request`
564
                 * to here, etc.
565
                 *
566
                 * @param WPGraphQLResult      $response       The GraphQL response
567
                 * @param WPGraphQLResult      $result         Deprecated. Same as $response.
568
                 * @param string               $operation_name The name of the operation
569
                 * @param string               $query          The request that GraphQL executed
570
                 * @param ?array<string,mixed> $variables      Variables to passed to your GraphQL query
571
                 * @param int|string           $status_code    The status code for the response
572
                 *
573
                 * @since 0.0.5
574
                 */
575
                do_action( 'graphql_process_http_request_response', $response, $response, $operation_name, $query, $variables, self::$http_status_code );
×
576

577
                /**
578
                 * Send the response
579
                 */
580
                wp_send_json( $response );
×
581
        }
582

583
        /**
584
         * Prepare headers for response
585
         *
586
         * @param mixed[]|\GraphQL\Executor\ExecutionResult $response       The response of the GraphQL Request.
587
         * @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated    Deprecated.
588
         * @param string                                    $query          The GraphQL query.
589
         * @param string                                    $operation_name The operation name of the GraphQL Request.
590
         * @param ?array<string,mixed>                      $variables      The variables applied to the GraphQL Request.
591
         * @param ?\WP_User                                 $user           The current user object.
592
         *
593
         * @return void
594
         */
595
        protected static function prepare_headers( $response, $_deprecated, string $query, string $operation_name, $variables, $user = null ) {
×
596

597
                /**
598
                 * Filter the $status_code before setting the headers
599
                 *
600
                 * @param int                                       $status_code    The status code to apply to the headers
601
                 * @param mixed[]|\GraphQL\Executor\ExecutionResult $response       The response of the GraphQL Request
602
                 * @param mixed[]|\GraphQL\Executor\ExecutionResult $_deprecated    Use $response instead.
603
                 * @param string                                    $query          The GraphQL query
604
                 * @param string                                    $operation_name The operation name of the GraphQL Request
605
                 * @param ?array<string,mixed>                      $variables      The variables applied to the GraphQL Request
606
                 * @param ?\WP_User                                 $user           The current user object
607
                 */
608
                self::$http_status_code = apply_filters( 'graphql_response_status_code', self::$http_status_code, $_deprecated, $response, $query, $operation_name, $variables, $user );
×
609

610
                /**
611
                 * Set the response headers
612
                 */
613
                self::set_headers();
×
614
        }
615

616
        /**
617
         * @deprecated 0.4.1 Use Router::is_graphql_http_request instead. This now resolves to it
618
         * @todo remove in v3.0
619
         * @codeCoverageIgnore
620
         *
621
         * @return bool
622
         */
623
        public static function is_graphql_request() {
624
                _doing_it_wrong(
625
                        __METHOD__,
626
                        sprintf(
627
                                /* translators: %s is the class name */
628
                                esc_html__( 'This method is deprecated and will be removed in the next major version of WPGraphQL. Use %s instead.', 'wp-graphql' ),
629
                                esc_html( self::class . '::is_graphql_http_request()' )
630
                        ),
631
                        '0.4.1'
632
                );
633
                return self::is_graphql_http_request();
634
        }
635

636
        /**
637
         * Validates HTTP request authentication BEFORE any GraphQL processing begins.
638
         *
639
         * This method provides CSRF protection for cookie-authenticated requests.
640
         * It runs before `graphql_process_http_request` and other hooks fire, ensuring
641
         * plugins cannot inadvertently expose sensitive data based on a user identity
642
         * that hasn't been validated yet.
643
         *
644
         * For cookie-authenticated requests:
645
         * - No nonce provided: User is downgraded to guest (CSRF protection)
646
         * - Invalid nonce: Returns WP_Error (caller should return error response)
647
         * - Valid nonce: Returns null (authentication preserved)
648
         *
649
         * @since 2.6.0
650
         *
651
         * @return \WP_Error|null WP_Error if invalid nonce, null otherwise.
652
         */
653
        public static function validate_http_request_authentication(): ?\WP_Error {
9✔
654
                /**
655
                 * Only validate for logged-in users.
656
                 * Guest users don't need validation - they're already unauthenticated.
657
                 */
658
                if ( ! is_user_logged_in() ) {
9✔
659
                        return null;
1✔
660
                }
661

662
                /**
663
                 * Check if an Authorization header is present.
664
                 * If so, this is likely a non-cookie auth method (JWT, Application Passwords, etc.)
665
                 * which are inherently CSRF-safe and don't need nonce validation.
666
                 */
667
                $has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] )
8✔
668
                        || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
8✔
669

670
                if ( $has_auth_header ) {
8✔
671
                        return null;
×
672
                }
673

674
                /**
675
                 * No Authorization header = cookie-based authentication.
676
                 * Check for nonce in request param or header.
677
                 */
678
                $nonce = null;
8✔
679

680
                if ( isset( $_REQUEST['_wpnonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
8✔
681
                        $nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
3✔
682
                } elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
5✔
683
                        $nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1✔
684
                }
685

686
                /**
687
                 * Treat "falsy" nonce values as "no nonce provided".
688
                 * This handles JavaScript serialization edge cases where null/undefined
689
                 * get converted to strings.
690
                 */
691
                $empty_nonce_values = [ '', 'null', 'undefined', 'false', '0' ];
8✔
692
                if ( in_array( $nonce, $empty_nonce_values, true ) ) {
8✔
693
                        $nonce = null;
×
694
                }
695

696
                /**
697
                 * Filter whether to require a nonce for cookie-based authentication.
698
                 *
699
                 * By default, WPGraphQL requires a nonce (X-WP-Nonce header or _wpnonce parameter)
700
                 * for cookie-authenticated requests to prevent CSRF attacks.
701
                 *
702
                 * @since next-version
703
                 *
704
                 * @param bool $require_nonce Whether to require a nonce for cookie auth. Default true.
705
                 * @param null $request       The Request instance (null in Router context).
706
                 */
707
                $require_nonce = apply_filters( 'graphql_cookie_auth_require_nonce', true, null );
8✔
708

709
                /**
710
                 * If nonce is not required, allow the authenticated request.
711
                 */
712
                if ( ! $require_nonce ) {
8✔
713
                        return null;
×
714
                }
715

716
                /**
717
                 * No nonce provided - downgrade to guest (unless plugin prevents it).
718
                 */
719
                if ( null === $nonce ) {
8✔
720
                        /**
721
                         * Allow plugins to prevent the downgrade via the graphql_authentication_errors filter.
722
                         *
723
                         * @param bool|null                $authentication_errors Null to allow default behavior, false to preserve auth.
724
                         * @param \WPGraphQL\Request|null  $request               The Request instance (null in Router context).
725
                         */
726
                        $filtered = apply_filters( 'graphql_authentication_errors', null, self::get_request() );
4✔
727

728
                        // If a plugin explicitly returned false (no errors), preserve authentication
729
                        if ( false === $filtered ) {
4✔
730
                                return null;
1✔
731
                        }
732

733
                        // Downgrade to guest
734
                        wp_set_current_user( 0 );
3✔
735
                        return null;
3✔
736
                }
737

738
                /**
739
                 * Nonce provided - validate it.
740
                 * Support both 'wp_graphql' and 'wp_rest' for backward compatibility.
741
                 */
742
                $nonce_valid = wp_verify_nonce( $nonce, 'wp_graphql' ) || wp_verify_nonce( $nonce, 'wp_rest' );
4✔
743

744
                if ( ! $nonce_valid ) {
4✔
745
                        return new \WP_Error(
1✔
746
                                'graphql_cookie_invalid_nonce',
1✔
747
                                __( 'Cookie nonce is invalid', 'wp-graphql' ),
1✔
748
                                [ 'status' => 403 ]
1✔
749
                        );
1✔
750
                }
751

752
                return null;
3✔
753
        }
754
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc