Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wp-graphql/access-functions.php
+++ b/wp-graphql/access-functions.php
@@ -691,6 +691,42 @@
}
/**
+ * Generate a WPGraphQL nonce for cookie-based authentication.
+ *
+ * This nonce is required for authenticated GraphQL HTTP requests that use
+ * WordPress cookie authentication. It provides CSRF protection by proving
+ * the request originated from a legitimate WordPress-generated page.
+ *
+ * Example usage in JavaScript:
+ * ```javascript
+ * fetch('/graphql', {
+ * headers: {
+ * 'Content-Type': 'application/json',
+ * 'X-WP-Nonce': wpGraphQLSettings.nonce
+ * },
+ * body: JSON.stringify({ query: '{ viewer { name } }' })
+ * });
+ * ```
+ *
+ * Example usage in PHP for localizing to JavaScript:
+ * ```php
+ * wp_localize_script( 'my-script', 'wpGraphQLSettings', [
+ * 'nonce' => graphql_get_nonce(),
+ * 'endpoint' => graphql_get_endpoint(),
+ * ] );
+ * ```
+ *
+ * @since 2.6.0
+ *
+ * @return string The WPGraphQL nonce string.
+ *
+ * @see https://github.com/wp-graphql/wp-graphql/issues/3447
+ */
+function graphql_get_nonce(): string {
+ return wp_create_nonce( 'wp_graphql' );
+}
+
+/**
* Registers a GraphQL Settings Section
*
* @param string $slug The slug of the group being registered
--- a/wp-graphql/constants.php
+++ b/wp-graphql/constants.php
@@ -18,7 +18,7 @@
// Plugin version.
if ( ! defined( 'WPGRAPHQL_VERSION' ) ) {
- define( 'WPGRAPHQL_VERSION', '2.5.3' );
+ define( 'WPGRAPHQL_VERSION', '2.5.4' );
}
// Plugin Folder Path.
--- a/wp-graphql/src/Data/DataSource.php
+++ b/wp-graphql/src/Data/DataSource.php
@@ -352,11 +352,13 @@
$allowed_settings_by_group = [];
foreach ( $registered_settings as $key => $setting ) {
// Bail if the setting doesn't have a group.
- if ( empty( $setting['group'] ) ) {
+ if ( ! isset( $setting['group'] ) || empty( $setting['group'] ) ) {
continue;
}
- $group = self::format_group_name( $setting['group'] );
+ /** @var string $setting_group */
+ $setting_group = $setting['group'];
+ $group = self::format_group_name( $setting_group );
if ( ! isset( $setting['type'] ) || ! $type_registry->get_type( $setting['type'] ) ) {
continue;
@@ -381,7 +383,7 @@
/**
* Filter the $allowed_settings_by_group to allow enabling or disabling groups in the GraphQL Schema.
*
- * @param array<string,array<string,mixed>> $allowed_settings_by_group
+ * @since 0.0.1
*/
return apply_filters( 'graphql_allowed_settings_by_group', $allowed_settings_by_group );
}
@@ -437,7 +439,7 @@
* Filter the $allowed_settings to allow some to be enabled or disabled from showing in
* the GraphQL Schema.
*
- * @param array<string,array<string,mixed>> $allowed_settings
+ * @since 0.0.1
*/
return apply_filters( 'graphql_allowed_setting_groups', $allowed_settings );
}
--- a/wp-graphql/src/Request.php
+++ b/wp-graphql/src/Request.php
@@ -2,7 +2,6 @@
namespace WPGraphQL;
-use Exception;
use GraphQLErrorDebugFlag;
use GraphQLErrorError;
use GraphQLGraphQL;
@@ -115,6 +114,14 @@
protected $query_analyzer;
/**
+ * Authentication error stored during before_execute().
+ * If set, the request should return this error instead of executing the query.
+ *
+ * @var WP_Error|bool|null
+ */
+ protected $authentication_error = null;
+
+ /**
* Constructor
*
* @param array<string,mixed> $data The request data (for Non-HTTP requests).
@@ -250,6 +257,42 @@
}
/**
+ * Reset authentication error state for this execution.
+ *
+ * This ensures each batch item starts with clean auth state, preventing
+ * errors from one batch item incorrectly persisting to subsequent items.
+ *
+ * @since next-version
+ */
+ $this->authentication_error = null;
+
+ /**
+ * Check for authentication errors via the graphql_authentication_errors filter.
+ *
+ * Note: For HTTP requests, all CSRF protection and nonce validation is
+ * handled by Router::validate_http_request_authentication() before this
+ * code runs. This call allows plugins to hook in and indicate auth errors.
+ *
+ * @since next-version CSRF protection and nonce validation moved to Router.
+ */
+ $auth_error = $this->has_authentication_errors();
+
+ if ( false !== $auth_error ) {
+ // Store the authentication error for later use in execute methods
+ $this->authentication_error = $auth_error;
+ }
+
+ /**
+ * Update AppContext->viewer to reflect the current user after auth check.
+ *
+ * If the user was downgraded due to missing nonce (CSRF protection),
+ * the viewer should reflect the guest user, not the originally authenticated user.
+ *
+ * @since 2.6.0
+ */
+ $this->app_context->viewer = wp_get_current_user();
+
+ /**
* If the request is a batch request it will come back as an array
*/
if ( is_array( $this->params ) ) {
@@ -295,81 +338,25 @@
}
/**
- * Checks authentication errors.
+ * Checks authentication errors via the graphql_authentication_errors filter.
*
- * False will mean there are no detected errors and
- * execution will continue.
+ * As of 2.6.0, all CSRF protection and nonce validation for HTTP requests is
+ * handled by Router::validate_http_request_authentication() BEFORE any GraphQL
+ * hooks fire. This method now only provides:
+ * - Plugin integration via the graphql_authentication_errors filter
*
- * Anything else (true, WP_Error, thrown exception, etc) will prevent execution of the GraphQL
- * request.
+ * False means no errors and execution continues.
+ * True or WP_Error prevents execution of the GraphQL request.
*
- * @return bool
- * @throws Exception
+ * @since 0.0.5
+ * @since 2.6.0 CSRF protection and nonce validation moved to Router.
+ *
+ * @return bool|WP_Error False if no errors, true or WP_Error if there are errors.
+ *
+ * @see Router::validate_http_request_authentication()
*/
protected function has_authentication_errors() {
- /**
- * Bail if this is not an HTTP request.
- *
- * Auth for internal requests will happen
- * via WordPress internals.
- */
- if ( ! is_graphql_http_request() ) {
- return false;
- }
-
- /**
- * Access the global $wp_rest_auth_cookie
- */
- global $wp_rest_auth_cookie;
-
- /**
- * Default state of the authentication errors
- */
- $authentication_errors = false;
-
- /**
- * Is cookie authentication NOT being used?
- *
- * If we get an auth error, but the user is still logged in, another auth mechanism
- * (JWT, oAuth, etc) must have been used.
- */
- if ( true !== $wp_rest_auth_cookie && is_user_logged_in() ) {
-
- /**
- * Return filtered authentication errors
- */
- return $this->filtered_authentication_errors( $authentication_errors );
- }
-
- /**
- * If the user is not logged in, determine if there's a nonce
- */
- $nonce = null;
-
- if ( isset( $_REQUEST['_wpnonce'] ) ) {
- $nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- } elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
- $nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- }
-
- if ( null === $nonce ) {
- // No nonce at all, so act as if it's an unauthenticated request.
- wp_set_current_user( 0 );
-
- return $this->filtered_authentication_errors( $authentication_errors );
- }
-
- // Check the nonce.
- $result = wp_verify_nonce( $nonce, 'wp_rest' );
-
- if ( ! $result ) {
- throw new Exception( esc_html__( 'Cookie nonce is invalid', 'wp-graphql' ) );
- }
-
- /**
- * Return the filtered authentication errors
- */
- return $this->filtered_authentication_errors( $authentication_errors );
+ return $this->filtered_authentication_errors( false );
}
/**
@@ -400,17 +387,16 @@
* @param T $response The response from execution. Array for batch requests, single object for individual requests.
*
* @return T
- *
- * @throws Exception
*/
private function after_execute( $response ) {
/**
- * If there are authentication errors, prevent execution and throw an exception.
+ * Authentication check has been moved to before_execute() as of 2.6.0.
+ * This ensures auth is validated BEFORE query execution, not after.
+ *
+ * @since 2.6.0 Auth check moved to before_execute()
+ * @see https://github.com/wp-graphql/wp-graphql/issues/3447
*/
- if ( false !== $this->has_authentication_errors() ) {
- throw new Exception( esc_html__( 'Authentication Error', 'wp-graphql' ) );
- }
/**
* If the params and the $response are both arrays
@@ -622,6 +608,35 @@
$this->before_execute();
/**
+ * If there was an authentication error, return it as a GraphQL error response
+ * instead of executing the query.
+ *
+ * IMPORTANT: This intentionally happens BEFORE the `pre_graphql_execute_request` filter.
+ * Authentication failures should fail fast for security reasons:
+ * - Don't give plugins a chance to interfere with or "undo" auth failures
+ * - Avoid unnecessary filter processing for failed requests
+ * - Ensure consistent, predictable auth error handling
+ *
+ * Plugins that need to observe ALL requests (including auth failures) should use
+ * earlier hooks like `graphql_before_execute` or `do_graphql_request`.
+ */
+ if ( null !== $this->authentication_error ) {
+ $error_message = is_wp_error( $this->authentication_error )
+ ? $this->authentication_error->get_error_message()
+ : __( 'Authentication Error', 'wp-graphql' );
+
+ return $this->after_execute(
+ [
+ 'errors' => [
+ [
+ 'message' => esc_html( $error_message ),
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
* Filter this to be anything other than null to short-circuit the request.
*
* @param ?SerializableResult $response
@@ -702,6 +717,26 @@
$this->before_execute();
/**
+ * If there was an authentication error, return it as a GraphQL error response
+ * instead of executing the query. This ensures consistent error handling.
+ */
+ if ( null !== $this->authentication_error ) {
+ $error_message = is_wp_error( $this->authentication_error )
+ ? $this->authentication_error->get_error_message()
+ : __( 'Authentication Error', 'wp-graphql' );
+
+ return $this->after_execute(
+ [
+ 'errors' => [
+ [
+ 'message' => esc_html( $error_message ),
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
* Get the response.
*/
$response = apply_filters( 'pre_graphql_execute_request', null, $this );
--- a/wp-graphql/src/Router.php
+++ b/wp-graphql/src/Router.php
@@ -437,6 +437,47 @@
}
/**
+ * Validate authentication BEFORE any GraphQL hooks fire.
+ *
+ * This is critical for security - we must validate/downgrade authentication
+ * before plugins can hook in and potentially expose sensitive information
+ * based on the (not-yet-validated) authenticated user.
+ *
+ * For cookie-authenticated requests:
+ * - No nonce: User is downgraded to guest
+ * - Invalid nonce: Returns error response immediately
+ * - Valid nonce: Proceeds normally
+ *
+ * @since 2.6.0
+ */
+ $auth_error = self::validate_http_request_authentication();
+
+ if ( is_wp_error( $auth_error ) ) {
+ /**
+ * Filter the HTTP status code returned for authentication errors.
+ *
+ * By default, invalid nonce errors return 403 Forbidden. Some clients
+ * may expect 200 with a GraphQL error response instead.
+ *
+ * @since 2.6.0
+ *
+ * @param int $status_code The HTTP status code. Default 403.
+ * @param WP_Error $auth_error The authentication error.
+ */
+ self::$http_status_code = apply_filters( 'graphql_authentication_error_status_code', 403, $auth_error );
+ self::set_headers();
+ wp_send_json(
+ [
+ 'errors' => [
+ [
+ 'message' => $auth_error->get_error_message(),
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
* This action can be hooked to to enable various debug tools,
* such as enableValidation from the GraphQL Config.
*
@@ -591,4 +632,123 @@
);
return self::is_graphql_http_request();
}
+
+ /**
+ * Validates HTTP request authentication BEFORE any GraphQL processing begins.
+ *
+ * This method provides CSRF protection for cookie-authenticated requests.
+ * It runs before `graphql_process_http_request` and other hooks fire, ensuring
+ * plugins cannot inadvertently expose sensitive data based on a user identity
+ * that hasn't been validated yet.
+ *
+ * For cookie-authenticated requests:
+ * - No nonce provided: User is downgraded to guest (CSRF protection)
+ * - Invalid nonce: Returns WP_Error (caller should return error response)
+ * - Valid nonce: Returns null (authentication preserved)
+ *
+ * @since 2.6.0
+ *
+ * @return WP_Error|null WP_Error if invalid nonce, null otherwise.
+ */
+ public static function validate_http_request_authentication(): ?WP_Error {
+ /**
+ * Only validate for logged-in users.
+ * Guest users don't need validation - they're already unauthenticated.
+ */
+ if ( ! is_user_logged_in() ) {
+ return null;
+ }
+
+ /**
+ * Check if an Authorization header is present.
+ * If so, this is likely a non-cookie auth method (JWT, Application Passwords, etc.)
+ * which are inherently CSRF-safe and don't need nonce validation.
+ */
+ $has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] )
+ || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
+
+ if ( $has_auth_header ) {
+ return null;
+ }
+
+ /**
+ * No Authorization header = cookie-based authentication.
+ * Check for nonce in request param or header.
+ */
+ $nonce = null;
+
+ if ( isset( $_REQUEST['_wpnonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ } elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
+ $nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ }
+
+ /**
+ * Treat "falsy" nonce values as "no nonce provided".
+ * This handles JavaScript serialization edge cases where null/undefined
+ * get converted to strings.
+ */
+ $empty_nonce_values = [ '', 'null', 'undefined', 'false', '0' ];
+ if ( in_array( $nonce, $empty_nonce_values, true ) ) {
+ $nonce = null;
+ }
+
+ /**
+ * Filter whether to require a nonce for cookie-based authentication.
+ *
+ * By default, WPGraphQL requires a nonce (X-WP-Nonce header or _wpnonce parameter)
+ * for cookie-authenticated requests to prevent CSRF attacks.
+ *
+ * @since next-version
+ *
+ * @param bool $require_nonce Whether to require a nonce for cookie auth. Default true.
+ * @param null $request The Request instance (null in Router context).
+ */
+ $require_nonce = apply_filters( 'graphql_cookie_auth_require_nonce', true, null );
+
+ /**
+ * If nonce is not required, allow the authenticated request.
+ */
+ if ( ! $require_nonce ) {
+ return null;
+ }
+
+ /**
+ * No nonce provided - downgrade to guest (unless plugin prevents it).
+ */
+ if ( null === $nonce ) {
+ /**
+ * Allow plugins to prevent the downgrade via the graphql_authentication_errors filter.
+ *
+ * @param bool|null $authentication_errors Null to allow default behavior, false to preserve auth.
+ * @param WPGraphQLRequest|null $request The Request instance (null in Router context).
+ */
+ $filtered = apply_filters( 'graphql_authentication_errors', null, self::get_request() );
+
+ // If a plugin explicitly returned false (no errors), preserve authentication
+ if ( false === $filtered ) {
+ return null;
+ }
+
+ // Downgrade to guest
+ wp_set_current_user( 0 );
+ return null;
+ }
+
+ /**
+ * Nonce provided - validate it.
+ * Support both 'wp_graphql' and 'wp_rest' for backward compatibility.
+ */
+ $nonce_valid = wp_verify_nonce( $nonce, 'wp_graphql' ) || wp_verify_nonce( $nonce, 'wp_rest' );
+
+ if ( ! $nonce_valid ) {
+ return new WP_Error(
+ 'graphql_cookie_invalid_nonce',
+ __( 'Cookie nonce is invalid', 'wp-graphql' ),
+ [ 'status' => 403 ]
+ );
+ }
+
+ return null;
+ }
}
--- a/wp-graphql/vendor/autoload.php
+++ b/wp-graphql/vendor/autoload.php
@@ -19,4 +19,4 @@
require_once __DIR__ . '/composer/autoload_real.php';
-return ComposerAutoloaderInit716023593395f61e373fc24f6ad29892::getLoader();
+return ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d::getLoader();
--- a/wp-graphql/vendor/composer/autoload_real.php
+++ b/wp-graphql/vendor/composer/autoload_real.php
@@ -2,7 +2,7 @@
// autoload_real.php @generated by Composer
-class ComposerAutoloaderInit716023593395f61e373fc24f6ad29892
+class ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d
{
private static $loader;
@@ -24,12 +24,12 @@
require __DIR__ . '/platform_check.php';
- spl_autoload_register(array('ComposerAutoloaderInit716023593395f61e373fc24f6ad29892', 'loadClassLoader'), true, true);
+ spl_autoload_register(array('ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d', 'loadClassLoader'), true, true);
self::$loader = $loader = new ComposerAutoloadClassLoader(dirname(__DIR__));
- spl_autoload_unregister(array('ComposerAutoloaderInit716023593395f61e373fc24f6ad29892', 'loadClassLoader'));
+ spl_autoload_unregister(array('ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
- call_user_func(ComposerAutoloadComposerStaticInit716023593395f61e373fc24f6ad29892::getInitializer($loader));
+ call_user_func(ComposerAutoloadComposerStaticInite2274500fb78db53b928b27209e36f4d::getInitializer($loader));
$loader->register(true);
--- a/wp-graphql/vendor/composer/autoload_static.php
+++ b/wp-graphql/vendor/composer/autoload_static.php
@@ -4,7 +4,7 @@
namespace ComposerAutoload;
-class ComposerStaticInit716023593395f61e373fc24f6ad29892
+class ComposerStaticInite2274500fb78db53b928b27209e36f4d
{
public static $prefixLengthsPsr4 = array (
'W' =>
@@ -503,9 +503,9 @@
public static function getInitializer(ClassLoader $loader)
{
return Closure::bind(function () use ($loader) {
- $loader->prefixLengthsPsr4 = ComposerStaticInit716023593395f61e373fc24f6ad29892::$prefixLengthsPsr4;
- $loader->prefixDirsPsr4 = ComposerStaticInit716023593395f61e373fc24f6ad29892::$prefixDirsPsr4;
- $loader->classMap = ComposerStaticInit716023593395f61e373fc24f6ad29892::$classMap;
+ $loader->prefixLengthsPsr4 = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$prefixLengthsPsr4;
+ $loader->prefixDirsPsr4 = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$prefixDirsPsr4;
+ $loader->classMap = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$classMap;
}, null, ClassLoader::class);
}
--- a/wp-graphql/vendor/composer/installed.php
+++ b/wp-graphql/vendor/composer/installed.php
@@ -1,9 +1,9 @@
<?php return array(
'root' => array(
'name' => 'wp-graphql/wp-graphql',
- 'pretty_version' => 'v2.5.3',
- 'version' => '2.5.3.0',
- 'reference' => '5b7e6886fd1cd8b447792a109c31bfbca87eafa3',
+ 'pretty_version' => 'v2.5.4',
+ 'version' => '2.5.4.0',
+ 'reference' => 'fae60f99a795ea9d8bcb4167d9e72f2c91a77507',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -38,9 +38,9 @@
'dev_requirement' => false,
),
'wp-graphql/wp-graphql' => array(
- 'pretty_version' => 'v2.5.3',
- 'version' => '2.5.3.0',
- 'reference' => '5b7e6886fd1cd8b447792a109c31bfbca87eafa3',
+ 'pretty_version' => 'v2.5.4',
+ 'version' => '2.5.4.0',
+ 'reference' => 'fae60f99a795ea9d8bcb4167d9e72f2c91a77507',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
--- a/wp-graphql/wp-graphql.php
+++ b/wp-graphql/wp-graphql.php
@@ -7,7 +7,7 @@
* Description: GraphQL API for WordPress
* Author: WPGraphQL
* Author URI: http://www.wpgraphql.com
- * Version: 2.5.3
+ * Version: 2.5.4
* Text Domain: wp-graphql
* Domain Path: /languages/
* Requires at least: 6.0
@@ -19,7 +19,7 @@
* @package WPGraphQL
* @category Core
* @author WPGraphQL
- * @version 2.5.3
+ * @version 2.5.4
*/
// Exit if accessed directly.