Created
December 11, 2025 19:55
-
-
Save jasonbahl/7549d70947435af5e8d829acb84f35b6 to your computer and use it in GitHub Desktop.
Fix for authenticated cache leak vulnerability
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/src/Cache/Query.php b/src/Cache/Query.php | |
| index ec5cfdc..3435ac0 100644 | |
| --- a/src/Cache/Query.php | |
| +++ b/src/Cache/Query.php | |
| @@ -22,6 +22,13 @@ class Query { | |
| **/ | |
| public static $storage = null; | |
| + /** | |
| + * The current GraphQL request. | |
| + * | |
| + * @var \WPGraphQL\Request|null | |
| + */ | |
| + protected $request; | |
| + | |
| /** | |
| * @return void | |
| */ | |
| @@ -60,14 +67,15 @@ class Query { | |
| return false; | |
| } | |
| - // WP_User | |
| - $user = wp_get_current_user(); | |
| + // Get user ID from AppContext->viewer which is set at Request creation | |
| + // and doesn't change even if wp_set_current_user(0) is called later | |
| + $user_id = $this->request->app_context->viewer->ID ?? null; | |
| $parts = [ | |
| 'query' => $query, | |
| 'variables' => $variables ?: null, | |
| 'operation' => $operation ?: null, | |
| - 'user' => $user->ID, | |
| + 'user' => $user_id, | |
| ]; | |
| $parts_string = wp_json_encode( $parts ); | |
| diff --git a/src/Cache/Results.php b/src/Cache/Results.php | |
| index 27b2e85..163e559 100644 | |
| --- a/src/Cache/Results.php | |
| +++ b/src/Cache/Results.php | |
| @@ -22,9 +22,14 @@ class Results extends Query { | |
| protected $is_cached = []; | |
| /** | |
| - * @var \WPGraphQL\Request | |
| + * Stores whether the object cache is enabled. | |
| + * | |
| + * This is cached after first determination to ensure consistent behavior | |
| + * throughout the request lifecycle, even if WordPress auth state changes. | |
| + * | |
| + * @var bool|null | |
| */ | |
| - protected $request; | |
| + protected $is_object_cache_enabled = null; | |
| /** | |
| * @return void | |
| @@ -36,9 +41,35 @@ class Results extends Query { | |
| add_action( 'wpgraphql_cache_purge_all', [ $this, 'purge_all_cb' ], 10, 0 ); | |
| add_filter( 'graphql_request_results', [ $this, 'add_cache_key_to_response_extensions' ], 10, 7 ); | |
| + // Set Cache-Control: no-store for authenticated requests to prevent network/CDN caching | |
| + add_filter( 'graphql_response_headers_to_send', [ $this, 'add_no_cache_headers_for_authenticated_requests' ], PHP_INT_MAX ); | |
| + | |
| parent::init(); | |
| } | |
| + /** | |
| + * Add Cache-Control: no-store header for authenticated requests. | |
| + * | |
| + * This prevents network caches (Varnish, CDN, etc.) from caching responses | |
| + * that were made by authenticated users, which could contain sensitive data. | |
| + * | |
| + * Uses AppContext->viewer which is set at Request creation and doesn't change | |
| + * even if wp_set_current_user(0) is called later. | |
| + * | |
| + * @param array $headers The headers to be sent with the response. | |
| + * | |
| + * @return array The modified headers. | |
| + */ | |
| + public function add_no_cache_headers_for_authenticated_requests( $headers ) { | |
| + // Use the viewer from AppContext, which is set at Request creation | |
| + // and doesn't change even if wp_set_current_user(0) is called later | |
| + if ( $this->request && isset( $this->request->app_context->viewer ) && $this->request->app_context->viewer->exists() ) { | |
| + $headers['Cache-Control'] = 'no-store'; | |
| + } | |
| + | |
| + return $headers; | |
| + } | |
| + | |
| /** | |
| * Unique identifier for this request is normalized query string, operation and variables | |
| * | |
| @@ -111,6 +142,10 @@ class Results extends Query { | |
| public function get_query_results_from_cache_cb( $result, Request $request ) { | |
| $this->request = $request; | |
| + // Reset the cached is_object_cache_enabled value for each new request | |
| + // This ensures we re-evaluate based on the current request's auth state | |
| + $this->is_object_cache_enabled = null; | |
| + | |
| // if caching is not enabled or the request is authenticated, bail early | |
| // right now we're not supporting GraphQL cache for authenticated requests. | |
| // Possibly in the future. | |
| @@ -177,6 +212,12 @@ class Results extends Query { | |
| */ | |
| protected function is_object_cache_enabled() { | |
| + // Return cached value if already determined for this request. | |
| + // This ensures consistent behavior even if WordPress auth state changes mid-request. | |
| + if ( null !== $this->is_object_cache_enabled ) { | |
| + return (bool) $this->is_object_cache_enabled; | |
| + } | |
| + | |
| // default to disabled | |
| $enabled = false; | |
| @@ -185,13 +226,21 @@ class Results extends Query { | |
| $enabled = true; | |
| } | |
| - // however, if the user is logged in, we should bypass the cache | |
| - if ( is_user_logged_in() ) { | |
| + // Check if the user is authenticated using AppContext->viewer. | |
| + // This is more reliable than is_user_logged_in() because: | |
| + // 1. AppContext->viewer is set once at Request creation and doesn't change | |
| + // 2. WPGraphQL core may call wp_set_current_user(0) mid-request in has_authentication_errors(), | |
| + // or even within individual resolvers, etc which would cause is_user_logged_in() | |
| + // to return false even for authenticated requests | |
| + // 3. Using AppContext is more "GraphQL-native" and consistent with how WPGraphQL handles auth | |
| + if ( $this->request && isset( $this->request->app_context->viewer ) && $this->request->app_context->viewer->exists() ) { | |
| $enabled = false; | |
| } | |
| // @phpcs:ignore | |
| - return (bool) apply_filters( 'graphql_cache_is_object_cache_enabled', $enabled, $this->request ); | |
| + $this->is_object_cache_enabled = (bool) apply_filters( 'graphql_cache_is_object_cache_enabled', $enabled, $this->request ); | |
| + | |
| + return $this->is_object_cache_enabled; | |
| } | |
| /** | |
| diff --git a/tests/acceptance.suite.yml b/tests/acceptance.suite.yml | |
| index e030d03..d016a05 100644 | |
| --- a/tests/acceptance.suite.yml | |
| +++ b/tests/acceptance.suite.yml | |
| @@ -10,6 +10,7 @@ modules: | |
| enabled: | |
| - Asserts | |
| - WPBrowser | |
| + - WPDb | |
| - REST: | |
| depends: PhpBrowser | |
| part: Json | |
| @@ -24,3 +25,10 @@ modules: | |
| adminUsername: '%TEST_SITE_ADMIN_USERNAME%' | |
| adminPassword: '%TEST_SITE_ADMIN_PASSWORD%' | |
| adminPath: '/wp-admin' | |
| + WPDb: | |
| + dsn: 'mysql:host=%TEST_SITE_DB_HOST%;dbname=%TEST_SITE_DB_NAME%' | |
| + user: '%TEST_SITE_DB_USER%' | |
| + password: '%TEST_SITE_DB_PASSWORD%' | |
| + url: '%WP_URL%' | |
| + tablePrefix: '%TEST_SITE_TABLE_PREFIX%' | |
| + urlReplacement: false | |
| diff --git a/tests/acceptance/AuthenticatedRequestCacheCest.php b/tests/acceptance/AuthenticatedRequestCacheCest.php | |
| new file mode 100644 | |
| --- /dev/null | |
| +++ b/tests/acceptance/AuthenticatedRequestCacheCest.php | |
| @@ -0,0 +1, 173 @@ | |
| +<?php | |
| + | |
| +/** | |
| + * Test that authenticated request data does not leak to public users via cache. | |
| + * | |
| + * This uses acceptance tests with WPBrowser to properly handle authentication | |
| + * via the same browser session. | |
| + * | |
| + * The vulnerability was: WPGraphQL calls wp_set_current_user(0) mid-request, | |
| + * causing is_user_logged_in() to return false, which incorrectly cached | |
| + * authenticated results that could then leak to public users. | |
| + * | |
| + * The fix uses AppContext->viewer (set early, stable) instead of is_user_logged_in(). | |
| + */ | |
| +class AuthenticatedRequestCacheCest { | |
| + | |
| + /** | |
| + * @var int | |
| + */ | |
| + protected $draft_post_id; | |
| + | |
| + /** | |
| + * @var string | |
| + */ | |
| + protected $draft_post_title; | |
| + | |
| + /** | |
| + * @var string | |
| + */ | |
| + protected $test_run_id; | |
| + | |
| + public function _before( AcceptanceTester $I ) { | |
| + $this->test_run_id = uniqid( 'test_', true ); | |
| + $this->draft_post_title = 'Secret Draft ' . $this->test_run_id; | |
| + } | |
| + | |
| + /** | |
| + * Test that draft posts visible to admin don't leak to public users via cache. | |
| + * | |
| + * This test: | |
| + * 1. Logs in as admin via browser | |
| + * 2. Makes GraphQL request for drafts via browser URL (same session) | |
| + * 3. Verifies admin sees the draft | |
| + * 4. Makes same request via REST module (clean session, no cookies) | |
| + * 5. Verifies public user does NOT see the draft | |
| + */ | |
| + public function testDraftContentDoesNotLeakToPublicUsers( AcceptanceTester $I ) { | |
| + // Enable object cache | |
| + $I->haveOptionInDatabase( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] ); | |
| + | |
| + // Create draft post | |
| + $this->draft_post_id = $I->havePostInDatabase( [ | |
| + 'post_type' => 'post', | |
| + 'post_status' => 'draft', | |
| + 'post_title' => $this->draft_post_title, | |
| + 'post_content' => 'Secret content', | |
| + ] ); | |
| + | |
| + // Build unique query | |
| + $operation_name = 'TestDraft_' . str_replace( '.', '_', $this->test_run_id ); | |
| + $query = "query {$operation_name} { posts(where: {status: DRAFT}) { nodes { title status } } }"; | |
| + $query_encoded = urlencode( $query ); | |
| + $graphql_url = "/graphql?query={$query_encoded}"; | |
| + | |
| + // ===================================================== | |
| + // STEP 1: Admin logs in and makes GraphQL request | |
| + // ===================================================== | |
| + $I->loginAsAdmin(); | |
| + $I->amOnPage( $graphql_url ); | |
| + | |
| + // Grab the raw page source (JSON response) | |
| + $auth_body = $I->grabPageSource(); | |
| + codecept_debug( 'Authenticated response: ' . $auth_body ); | |
| + | |
| + $auth_response = json_decode( $auth_body, true ); | |
| + $I->assertIsArray( $auth_response, 'Response should be valid JSON' ); | |
| + $I->assertArrayHasKey( 'data', $auth_response, 'Response should have data key' ); | |
| + | |
| + // Admin should see the draft post | |
| + $auth_posts = $auth_response['data']['posts']['nodes'] ?? []; | |
| + $found_draft = false; | |
| + foreach ( $auth_posts as $post ) { | |
| + if ( $post['title'] === $this->draft_post_title ) { | |
| + $found_draft = true; | |
| + break; | |
| + } | |
| + } | |
| + $I->assertTrue( $found_draft, 'Authenticated admin should see the draft post' ); | |
| + | |
| + // Verify it was NOT served from cache (first request) | |
| + $auth_cache_info = $auth_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? []; | |
| + codecept_debug( 'Auth cache info: ' . json_encode( $auth_cache_info ) ); | |
| + $I->assertEquals( [], $auth_cache_info, 'Auth request should NOT be from cache' ); | |
| + | |
| + // ===================================================== | |
| + // STEP 2: Make same request as public user via REST (clean session) | |
| + // ===================================================== | |
| + // Use sendGet from REST module - this doesn't share cookies with WPBrowser | |
| + $I->deleteHeader( 'Authorization' ); | |
| + $I->sendGet( 'graphql', [ 'query' => $query ] ); | |
| + $I->seeResponseCodeIs( 200 ); | |
| + | |
| + $public_body = $I->grabResponse(); | |
| + codecept_debug( 'Public response: ' . $public_body ); | |
| + | |
| + $public_response = json_decode( $public_body, true ); | |
| + $I->assertIsArray( $public_response, 'Response should be valid JSON' ); | |
| + | |
| + // Check cache status | |
| + $public_cache_info = $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? []; | |
| + codecept_debug( 'Public cache info: ' . json_encode( $public_cache_info ) ); | |
| + | |
| + // CRITICAL SECURITY CHECK: Public user should NOT see draft posts | |
| + $public_posts = $public_response['data']['posts']['nodes'] ?? []; | |
| + codecept_debug( 'Public posts count: ' . count( $public_posts ) ); | |
| + | |
| + foreach ( $public_posts as $post ) { | |
| + $I->assertNotEquals( | |
| + $this->draft_post_title, | |
| + $post['title'], | |
| + 'SECURITY VULNERABILITY: Public user can see draft posts!' | |
| + ); | |
| + } | |
| + | |
| + // Public request should NOT come from cache (auth didn't cache it) | |
| + $I->assertEquals( | |
| + [], | |
| + $public_cache_info, | |
| + 'Public request should NOT be from cache - auth request should not have cached' | |
| + ); | |
| + | |
| + // Cleanup | |
| + $I->dontHavePostInDatabase( [ 'ID' => $this->draft_post_id ] ); | |
| + $I->dontHaveOptionInDatabase( 'graphql_cache_section' ); | |
| + } | |
| + | |
| + /** | |
| + * Test that public requests ARE properly cached. | |
| + */ | |
| + public function testPublicRequestsAreCached( AcceptanceTester $I ) { | |
| + // Enable object cache | |
| + $I->haveOptionInDatabase( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] ); | |
| + | |
| + $operation_name = 'TestPublic_' . str_replace( '.', '_', $this->test_run_id ); | |
| + $query = urlencode( "query {$operation_name} { __typename }" ); | |
| + $graphql_url = "/graphql?query={$query}"; | |
| + | |
| + // First request - NOT from cache | |
| + $I->amOnPage( $graphql_url ); | |
| + $body1 = $I->grabPageSource(); | |
| + codecept_debug( 'First response: ' . $body1 ); | |
| + | |
| + $response1 = json_decode( $body1, true ); | |
| + $I->assertIsArray( $response1, 'Response should be valid JSON' ); | |
| + | |
| + $cache1 = $response1['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? []; | |
| + $I->assertEquals( [], $cache1, 'First request should NOT be from cache' ); | |
| + | |
| + // Second request - SHOULD be from cache | |
| + $I->amOnPage( $graphql_url ); | |
| + $body2 = $I->grabPageSource(); | |
| + codecept_debug( 'Second response: ' . $body2 ); | |
| + | |
| + $response2 = json_decode( $body2, true ); | |
| + $I->assertIsArray( $response2, 'Response should be valid JSON' ); | |
| + | |
| + $cache2 = $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? []; | |
| + $I->assertNotEmpty( $cache2, 'Second request SHOULD be from cache' ); | |
| + | |
| + // Cleanup | |
| + $I->dontHaveOptionInDatabase( 'graphql_cache_section' ); | |
| + } | |
| +} | |
| diff --git a/tests/wpunit/AuthenticatedRequestCacheTest.php b/tests/wpunit/AuthenticatedRequestCacheTest.php | |
| new file mode 100644 | |
| --- /dev/null | |
| +++ b/tests/wpunit/AuthenticatedRequestCacheTest.php | |
| @@ -0,0 +1, 480 @@ | |
| +<?php | |
| + | |
| +namespace WPGraphQL\SmartCache; | |
| + | |
| +use WPGraphQL\SmartCache\Cache\Results; | |
| + | |
| +/** | |
| + * Test that authenticated requests are handled correctly by the object cache. | |
| + * | |
| + * ## Background | |
| + * | |
| + * This tests the caching behavior for authenticated vs unauthenticated requests. | |
| + * The object cache should only store results from unauthenticated requests to ensure | |
| + * that authenticated user data is not inadvertently served to public users. | |
| + * | |
| + * ## Request Execution Order in WPGraphQL\Request | |
| + * | |
| + * 1. Request is created, AppContext->viewer is set to current user (e.g., admin with ID 123) | |
| + * 2. before_execute() runs - stores globals, handles batch setup (NO auth checks here!) | |
| + * 3. Query executes, returning data based on admin's permissions (e.g., draft posts) | |
| + * 4. after_execute() is called | |
| + * 5. has_authentication_errors() is called which does: | |
| + * - Checks if nonce is present | |
| + * - If NO nonce: calls wp_set_current_user(0) to treat as unauthenticated | |
| + * - This has been the behavior since 2019 (see Request.php lines 355-361) | |
| + * 6. after_execute_actions() runs (lines 420-427) | |
| + * 7. 'graphql_return_response' action fires - THIS IS WHERE SMART CACHE SAVES TO CACHE | |
| + * | |
| + * ## The Challenge | |
| + * | |
| + * At step 7, if we check is_user_logged_in(), it returns FALSE because wp_set_current_user(0) | |
| + * was called in step 5. But the query results from step 3 contain authenticated data! | |
| + * | |
| + * So we would: | |
| + * - See is_user_logged_in() === false | |
| + * - Think "this is an unauthenticated request, safe to cache" | |
| + * - Cache authenticated data (draft posts, private content, etc.) | |
| + * - Public users then get this cached authenticated data | |
| + * | |
| + * ## Historical Context | |
| + * | |
| + * The comment in WPGraphQL core at line 408-409 says "prevent execution" but | |
| + * has_authentication_errors() runs in after_execute() - AFTER the query has already executed! | |
| + * | |
| + * GitHub Issue #38 (wp-graphql-jwt-authentication, July 2019) | |
| + * discusses this exact problem - authentication errors should halt execution BEFORE | |
| + * the query runs, not after. The issue suggests using the rest_authentication_errors | |
| + * hook pattern to abort processing early. While the issue is closed it seems it might not be fully addressed. | |
| + * | |
| + * @see https://github.com/wp-graphql/wp-graphql-jwt-authentication/issues/38 | |
| + * | |
| + * ## The Solution | |
| + * | |
| + * Instead of checking is_user_logged_in() (which changes mid-request), we check | |
| + * AppContext->viewer which is set once at Request creation and never changes. | |
| + * | |
| + * - AppContext->viewer is set in Request constructor via WPGraphQL::get_app_context() | |
| + * - This captures the REAL user at request start, before any nonce checks | |
| + * - Even after wp_set_current_user(0) is called, AppContext->viewer still reflects | |
| + * the original authenticated user | |
| + * | |
| + * Additionally, we cache the result of is_object_cache_enabled() per-request to ensure | |
| + * consistent behavior throughout the entire request lifecycle. | |
| + * | |
| + * @see vendor/wp-graphql/wp-graphql/src/Request.php lines 355-361 (wp_set_current_user(0)) | |
| + * @see vendor/wp-graphql/wp-graphql/src/Request.php lines 408-427 (execution order) | |
| + * @see vendor/wp-graphql/wp-graphql/src/WPGraphQL.php line 950 (AppContext->viewer set) | |
| + */ | |
| +class AuthenticatedRequestCacheTest extends \Codeception\TestCase\WPTestCase { | |
| + | |
| + /** | |
| + * @var \WP_User | |
| + */ | |
| + protected $admin_user; | |
| + | |
| + /** | |
| + * @var int | |
| + */ | |
| + protected $draft_post_id; | |
| + | |
| + /** | |
| + * @var int | |
| + */ | |
| + protected $published_post_id; | |
| + | |
| + public function setUp(): void { | |
| + parent::setUp(); | |
| + | |
| + // Enable object caching | |
| + add_option( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] ); | |
| + | |
| + // Create an admin user | |
| + $this->admin_user = self::factory()->user->create_and_get( [ | |
| + 'role' => 'administrator', | |
| + ] ); | |
| + | |
| + // Create a draft post (only visible to authenticated users with proper capabilities) | |
| + $this->draft_post_id = self::factory()->post->create( [ | |
| + 'post_type' => 'post', | |
| + 'post_status' => 'draft', | |
| + 'post_title' => 'Secret Draft Post', | |
| + 'post_author' => $this->admin_user->ID, | |
| + ] ); | |
| + | |
| + // Create a published post | |
| + $this->published_post_id = self::factory()->post->create( [ | |
| + 'post_type' => 'post', | |
| + 'post_status' => 'publish', | |
| + 'post_title' => 'Public Published Post', | |
| + ] ); | |
| + } | |
| + | |
| + public function tearDown(): void { | |
| + delete_option( 'graphql_cache_section' ); | |
| + wp_delete_post( $this->draft_post_id, true ); | |
| + wp_delete_post( $this->published_post_id, true ); | |
| + wp_delete_user( $this->admin_user->ID ); | |
| + | |
| + parent::tearDown(); | |
| + } | |
| + | |
| + /** | |
| + * Test that is_object_cache_enabled returns false when AppContext viewer exists (authenticated), | |
| + * even if wp_set_current_user(0) was called later. | |
| + * | |
| + * This is the core of the fix - using AppContext->viewer instead of | |
| + * is_user_logged_in() which can change mid-request. | |
| + */ | |
| + public function testCacheIsDisabledWhenAppContextViewerExists() { | |
| + // Log in as admin before creating the GraphQL request | |
| + wp_set_current_user( $this->admin_user->ID ); | |
| + | |
| + // Execute a simple query - this creates a Request with AppContext->viewer set to admin | |
| + $query = '{ __typename }'; | |
| + $response = graphql( [ 'query' => $query ] ); | |
| + | |
| + // The cache should have been disabled for this authenticated request | |
| + $this->assertEmpty( | |
| + $response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Authenticated request should not be cached' | |
| + ); | |
| + } | |
| + | |
| + /** | |
| + * Test that cache IS enabled for truly unauthenticated requests. | |
| + */ | |
| + public function testCacheIsEnabledForUnauthenticatedRequests() { | |
| + // Ensure no user is logged in | |
| + wp_set_current_user( 0 ); | |
| + | |
| + // Execute a simple query twice | |
| + $query = '{ __typename }'; | |
| + | |
| + // First request - should execute and cache | |
| + $response1 = graphql( [ 'query' => $query ] ); | |
| + $this->assertArrayHasKey( 'data', $response1 ); | |
| + | |
| + // Second request - should be served from cache | |
| + $response2 = graphql( [ 'query' => $query ] ); | |
| + | |
| + // The second response should indicate it came from cache | |
| + $this->assertNotEmpty( | |
| + $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Second unauthenticated request should be served from cache' | |
| + ); | |
| + } | |
| + | |
| + /** | |
| + * Test the real-world scenario with draft posts. | |
| + * | |
| + * This test replicates the scenario: | |
| + * | |
| + * 1. Admin user is logged in (authenticated via WordPress session/cookie) | |
| + * 2. Admin makes request: /graphql/?query={posts(where:{status:DRAFT}){nodes{title status}}} | |
| + * 3. Admin sees draft posts in response (expected - they have permission) | |
| + * 4. WPGraphQL core calls wp_set_current_user(0) because no nonce was provided | |
| + * 5. Smart cache attempts to save results - should be BLOCKED because user WAS authenticated | |
| + * 6. Public user makes the same request | |
| + * 7. Public user should NOT see the draft posts | |
| + * | |
| + * WITHOUT THE FIX: At step 5, is_user_logged_in() returns FALSE (because of step 4), | |
| + * so the cache thinks it's safe to save, and public users get cached draft posts. | |
| + * | |
| + * WITH THE FIX: At step 5, we check AppContext->viewer which still reflects the admin | |
| + * user from step 1, so we correctly block caching. | |
| + */ | |
| + public function testDraftPostsDoNotLeakFromAuthenticatedToPublicUsers() { | |
| + // ===================================================================== | |
| + // STEP 1: Admin user is logged in | |
| + // ===================================================================== | |
| + wp_set_current_user( $this->admin_user->ID ); | |
| + | |
| + // This is the exact query from the real-world scenario | |
| + // /graphql/?query={posts(where:{status:DRAFT}){nodes{title status}}} | |
| + $query = '{ | |
| + posts(where: {status: DRAFT}) { | |
| + nodes { | |
| + title | |
| + status | |
| + } | |
| + } | |
| + }'; | |
| + | |
| + // ===================================================================== | |
| + // STEP 2-3: Admin executes query and sees draft posts | |
| + // ===================================================================== | |
| + $admin_response = graphql( [ 'query' => $query ] ); | |
| + | |
| + // Verify admin can see the draft post | |
| + $this->assertArrayHasKey( 'data', $admin_response ); | |
| + $this->assertArrayHasKey( 'posts', $admin_response['data'] ); | |
| + | |
| + $admin_posts = $admin_response['data']['posts']['nodes']; | |
| + $found_draft = false; | |
| + foreach ( $admin_posts as $post ) { | |
| + if ( 'Secret Draft Post' === $post['title'] ) { | |
| + $found_draft = true; | |
| + // GraphQL returns status in lowercase | |
| + $this->assertEquals( 'draft', strtolower( $post['status'] ) ); | |
| + } | |
| + } | |
| + $this->assertTrue( $found_draft, 'Admin should see the draft post' ); | |
| + | |
| + // ===================================================================== | |
| + // STEP 4-5: Verify the response was NOT cached | |
| + // (This is where the fix prevents the issue) | |
| + // ===================================================================== | |
| + $this->assertEmpty( | |
| + $admin_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Admin request should NOT be served from cache' | |
| + ); | |
| + | |
| + // ===================================================================== | |
| + // STEP 6: Public user (incognito window) makes the same request | |
| + // ===================================================================== | |
| + wp_set_current_user( 0 ); // Simulate unauthenticated user | |
| + | |
| + $public_response = graphql( [ 'query' => $query ] ); | |
| + | |
| + // ===================================================================== | |
| + // STEP 7: Verify public user does NOT see draft posts | |
| + // ===================================================================== | |
| + $this->assertArrayHasKey( 'data', $public_response ); | |
| + $public_posts = $public_response['data']['posts']['nodes'] ?? []; | |
| + | |
| + // Public user should NOT see the draft post - this is the critical check | |
| + foreach ( $public_posts as $post ) { | |
| + $this->assertNotEquals( | |
| + 'Secret Draft Post', | |
| + $post['title'], | |
| + 'Public user should not see draft post that was visible to admin.' | |
| + ); | |
| + $this->assertNotEquals( | |
| + 'draft', | |
| + strtolower( $post['status'] ), | |
| + 'Public user should not see draft content.' | |
| + ); | |
| + } | |
| + | |
| + // Also verify the public response was NOT served from a polluted cache | |
| + $this->assertEmpty( | |
| + $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Public request should not be served from a cache that was potentially populated by authenticated user' | |
| + ); | |
| + } | |
| + | |
| + /** | |
| + * Test that authenticated query results are NOT cached and don't leak to public users. | |
| + * | |
| + * This simulates the full flow: | |
| + * 1. Admin user is logged in | |
| + * 2. Request is created with AppContext->viewer set to admin | |
| + * 3. Query executes and returns draft posts | |
| + * 4. Cache save is attempted but should be blocked | |
| + * 5. Public user makes same query | |
| + * 6. Verify: Public user should NOT see cached authenticated data | |
| + */ | |
| + public function testAuthenticatedQueryResultsAreNotCached() { | |
| + // Log in as admin | |
| + wp_set_current_user( $this->admin_user->ID ); | |
| + | |
| + $query = ' | |
| + query GetDraftPosts { | |
| + posts(where: {status: DRAFT}) { | |
| + nodes { | |
| + id | |
| + title | |
| + status | |
| + } | |
| + } | |
| + } | |
| + '; | |
| + | |
| + // Execute the query as authenticated user | |
| + $response = graphql( [ 'query' => $query ] ); | |
| + | |
| + // Verify we got the draft post in the response | |
| + $this->assertArrayHasKey( 'data', $response ); | |
| + $this->assertArrayHasKey( 'posts', $response['data'] ); | |
| + | |
| + // Now check that the result was NOT cached | |
| + $this->assertEmpty( | |
| + $response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Authenticated request should not be from cache' | |
| + ); | |
| + | |
| + // Now log out and make the same request | |
| + wp_set_current_user( 0 ); | |
| + | |
| + $public_response = graphql( [ 'query' => $query ] ); | |
| + | |
| + // The public response should also NOT be from cache (because the authenticated | |
| + // user's response should not have been cached in the first place) | |
| + $this->assertEmpty( | |
| + $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Public request should not be served from a cache that was populated by authenticated user' | |
| + ); | |
| + | |
| + // And the public response should NOT contain the draft post | |
| + $public_posts = $public_response['data']['posts']['nodes'] ?? []; | |
| + foreach ( $public_posts as $post ) { | |
| + $this->assertNotEquals( | |
| + 'Secret Draft Post', | |
| + $post['title'], | |
| + 'Public user should NOT see draft posts that were visible to authenticated user' | |
| + ); | |
| + } | |
| + } | |
| + | |
| + /** | |
| + * Test that the Cache-Control header method correctly identifies authenticated requests. | |
| + * | |
| + * This ensures that network caches (Varnish, CDN) also don't cache authenticated responses. | |
| + * The Cache-Control: no-store header tells upstream caches not to store the response. | |
| + * | |
| + * Note: The graphql_response_headers_to_send filter only fires for HTTP requests, | |
| + * not internal graphql() calls. So we test the method directly. | |
| + */ | |
| + public function testCacheControlHeaderSetForAuthenticatedRequests() { | |
| + // Log in as admin | |
| + wp_set_current_user( $this->admin_user->ID ); | |
| + | |
| + // Execute a query to set up the request on the Results instance | |
| + $query = '{ __typename }'; | |
| + graphql( [ 'query' => $query ] ); | |
| + | |
| + // Get the Results instance and test the header method directly | |
| + $results = new Results(); | |
| + $results->init(); | |
| + | |
| + // We need to set the request on the Results instance | |
| + // Use reflection to set the request property | |
| + $reflection = new \ReflectionClass( $results ); | |
| + $request_property = $reflection->getProperty( 'request' ); | |
| + $request_property->setAccessible( true ); | |
| + | |
| + // Create a mock request with an authenticated viewer | |
| + $mock_request = new \stdClass(); | |
| + $mock_request->app_context = new \stdClass(); | |
| + $mock_request->app_context->viewer = wp_get_current_user(); | |
| + | |
| + $request_property->setValue( $results, $mock_request ); | |
| + | |
| + // Test the header filter method directly | |
| + $headers = [ 'Content-Type' => 'application/json' ]; | |
| + $filtered_headers = $results->add_no_cache_headers_for_authenticated_requests( $headers ); | |
| + | |
| + $this->assertArrayHasKey( 'Cache-Control', $filtered_headers, 'Cache-Control header should be set for authenticated requests' ); | |
| + $this->assertEquals( 'no-store', $filtered_headers['Cache-Control'], 'Cache-Control should be no-store for authenticated requests' ); | |
| + } | |
| + | |
| + /** | |
| + * Test that Cache-Control header is NOT set to no-store for unauthenticated requests. | |
| + * | |
| + * Unauthenticated requests CAN be cached by network caches, so we should not set | |
| + * Cache-Control: no-store for them. | |
| + */ | |
| + public function testCacheControlHeaderNotSetForUnauthenticatedRequests() { | |
| + // Ensure no user is logged in | |
| + wp_set_current_user( 0 ); | |
| + | |
| + // Get the Results instance and test the header method directly | |
| + $results = new Results(); | |
| + $results->init(); | |
| + | |
| + // Use reflection to set the request property | |
| + $reflection = new \ReflectionClass( $results ); | |
| + $request_property = $reflection->getProperty( 'request' ); | |
| + $request_property->setAccessible( true ); | |
| + | |
| + // Create a mock request with an unauthenticated viewer | |
| + $mock_request = new \stdClass(); | |
| + $mock_request->app_context = new \stdClass(); | |
| + $mock_request->app_context->viewer = wp_get_current_user(); // Returns user with ID 0 | |
| + | |
| + $request_property->setValue( $results, $mock_request ); | |
| + | |
| + // Test the header filter method directly | |
| + $headers = [ 'Content-Type' => 'application/json' ]; | |
| + $filtered_headers = $results->add_no_cache_headers_for_authenticated_requests( $headers ); | |
| + | |
| + // Cache-Control should NOT be set to no-store | |
| + $this->assertNotEquals( | |
| + 'no-store', | |
| + $filtered_headers['Cache-Control'] ?? '', | |
| + 'Cache-Control should not be no-store for unauthenticated requests' | |
| + ); | |
| + } | |
| + | |
| + /** | |
| + * Test that the is_object_cache_enabled result is cached after first determination. | |
| + * | |
| + * This ensures consistent behavior throughout the request even if something | |
| + * tries to change the auth state mid-request. | |
| + * | |
| + * The property is reset at the start of each new request (in get_query_results_from_cache_cb) | |
| + * to ensure each request gets a fresh evaluation based on its own auth state. | |
| + */ | |
| + public function testIsObjectCacheEnabledResultIsCached() { | |
| + $results = new Results(); | |
| + $results->init(); | |
| + | |
| + // Ensure no user is logged in | |
| + wp_set_current_user( 0 ); | |
| + | |
| + // We need to set a mock request on the Results object | |
| + // Since we can't easily create a full Request object, we'll test via graphql() | |
| + $query = '{ __typename }'; | |
| + | |
| + // First request as unauthenticated - should enable cache | |
| + $response1 = graphql( [ 'query' => $query ] ); | |
| + | |
| + // Second identical request should come from cache | |
| + $response2 = graphql( [ 'query' => $query ] ); | |
| + | |
| + $this->assertNotEmpty( | |
| + $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Second request should be served from cache, proving cache is enabled' | |
| + ); | |
| + } | |
| + | |
| + /** | |
| + * Test that multiple sequential requests with different auth states are handled correctly. | |
| + * | |
| + * This ensures the per-request reset of is_object_cache_enabled works correctly. | |
| + */ | |
| + public function testSequentialRequestsWithDifferentAuthStates() { | |
| + $query = '{ __typename }'; | |
| + | |
| + // Request 1: Unauthenticated - should cache | |
| + wp_set_current_user( 0 ); | |
| + $response1 = graphql( [ 'query' => $query ] ); | |
| + $this->assertArrayHasKey( 'data', $response1 ); | |
| + | |
| + // Request 2: Unauthenticated again - should be from cache | |
| + $response2 = graphql( [ 'query' => $query ] ); | |
| + $this->assertNotEmpty( | |
| + $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Second unauthenticated request should be from cache' | |
| + ); | |
| + | |
| + // Request 3: Authenticated - should NOT use cache and NOT cache result | |
| + wp_set_current_user( $this->admin_user->ID ); | |
| + $response3 = graphql( [ 'query' => $query ] ); | |
| + $this->assertEmpty( | |
| + $response3['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Authenticated request should not be from cache' | |
| + ); | |
| + | |
| + // Request 4: Back to unauthenticated - cache should still work | |
| + // (the authenticated request should not have polluted the cache) | |
| + wp_set_current_user( 0 ); | |
| + $response4 = graphql( [ 'query' => $query ] ); | |
| + $this->assertNotEmpty( | |
| + $response4['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [], | |
| + 'Unauthenticated request after authenticated request should still use cache' | |
| + ); | |
| + } | |
| + | |
| +} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment