Skip to content

Conversation

@jasonbahl
Copy link
Collaborator

@jasonbahl jasonbahl commented Dec 12, 2025

Description

This PR fixes an issue where authenticated user data could be cached and subsequently served to public (unauthenticated) users.

The Problem

When a logged-in user makes a GraphQL request without a nonce (e.g., via a direct URL like /graphql?query={posts(where:{status:DRAFT}){nodes{title}}}), the following sequence occurs:

  1. Request starts: AppContext->viewer is set to the authenticated user (e.g., admin with ID 123)
  2. Query executes: Returns data based on the admin's permissions (draft posts, private content, etc.)
  3. has_authentication_errors() runs: Since no nonce was provided, WPGraphQL core calls wp_set_current_user(0)
  4. Cache logic runs: Smart Cache checks is_user_logged_in() to determine if caching is enabled

At step 4, is_user_logged_in() returns false because wp_set_current_user(0) was called in step 3. However, the query results from step 2 still contain authenticated data. This caused Smart Cache to incorrectly cache the authenticated response, which could then be served to public users.

The Solution

Instead of using is_user_logged_in() (which can change mid-request), we now use AppContext->viewer to determine the user's authentication state:

  • AppContext->viewer is set once at Request creation and never changes
  • Even after wp_set_current_user(0) is called, AppContext->viewer still reflects the original authenticated user
  • This provides a reliable, stable indicator of whether the request was made by an authenticated user

Changes

src/Cache/Query.php

  • Added $request property to access AppContext->viewer
  • Updated build_key() to use AppContext->viewer->ID instead of wp_get_current_user()->ID

src/Cache/Results.php

  • Updated is_object_cache_enabled() to check AppContext->viewer->exists() instead of is_user_logged_in()
  • Added caching of the is_object_cache_enabled result per-request to ensure consistent behavior
  • Added Cache-Control: no-store header for authenticated requests to prevent network/CDN caching

src/Cache/Collection.php

  • Set $this->request before calling build_key() so it can access AppContext->viewer

Test Coverage

Added comprehensive test coverage:

tests/wpunit/AuthenticatedRequestCacheTest.php - Unit tests covering:

  • Cache is disabled when AppContext->viewer exists (authenticated)
  • Cache is enabled for unauthenticated requests
  • Draft posts do not leak from authenticated to public users
  • Cache-Control: no-store header is set for authenticated requests
  • Sequential requests with different auth states are handled correctly

tests/acceptance/AuthenticatedRequestCacheCest.php - Acceptance tests covering:

  • Admin makes request → sees drafts → cache NOT populated
  • Admin makes second request → cache STILL not populated
  • Public user makes same request → no drafts visible, no cache leak
  • Public requests ARE properly cached
  • Authenticated users bypass existing cache entries

How to Test

  1. Enable object caching in WPGraphQL Smart Cache settings
  2. Create a draft post
  3. Log in as admin and visit: /graphql?query={posts(where:{status:DRAFT}){nodes{title status}}}
  4. Verify admin sees the draft post
  5. Open an incognito window and visit the same URL
  6. Verify the public user does NOT see the draft post

Related

This fix addresses a quirk in WPGraphQL core where wp_set_current_user(0) is called in after_execute() — after the query has already executed with authenticated permissions. A separate issue will be opened in WPGraphQL core to address the timing of this authentication check.


Note

Uses AppContext->viewer to disable caching for authenticated requests (and in cache keys), adds Cache-Control: no-store, and introduces tests to prevent draft/private data leaking to public users; updates CI and test configs.

  • Caching logic (src/Cache):
    • Results: Determine auth via AppContext->viewer (not is_user_logged_in()), cache the decision per-request, and set Cache-Control: no-store for authenticated requests; keep auth requests out of object cache.
    • Query: Add $request and use AppContext->viewer->ID in build_key() to segregate by user reliably.
    • Collection: Set $this->request before build_key() so cache keys include stable user context.
  • Tests:
    • Add wpunit/AuthenticatedRequestCacheTest.php covering auth vs public caching, header behavior, and sequential auth-state scenarios.
    • Add acceptance AuthenticatedRequestCacheCest.php verifying no leak of draft posts, public caching works, and auth bypasses cache.
    • Update tests/acceptance.suite.yml to use WPDb; add helper and config tweaks.
  • Tooling/CI:
    • Bump GitHub Actions (actions/checkout@v4, actions/cache@v4, [email protected]) and fix GITHUB_OUTPUT usage.
    • Update Codeception config (codeception.dist.yml) and composer dev setup/scripts; keep lockfile in sync.

Written by Cursor Bugbot for commit 7d8f951. This will update automatically on new commits. Configure here.

This fixes a security vulnerability where authenticated user data could leak
to public users through the object cache.

The issue stemmed from WPGraphQL core calling wp_set_current_user(0) in
has_authentication_errors() AFTER query execution but BEFORE the cache
save hook fires. This caused is_user_logged_in() to return false even
for authenticated requests.

The fix:
- Use AppContext->viewer instead of is_user_logged_in() to check auth state
- AppContext->viewer is set at Request creation and doesn't change mid-request
- Cache the is_object_cache_enabled() result per-request for consistency
- Add Cache-Control: no-store header for authenticated requests

See: wp-graphql/wp-graphql-jwt-authentication#38
This fixes a security vulnerability where authenticated user data could leak
to public users through the object cache.

The issue stemmed from WPGraphQL core calling wp_set_current_user(0) in
has_authentication_errors() AFTER query execution but BEFORE the cache
save hook fires. This caused is_user_logged_in() to return false even
for authenticated requests.

The fix:
- Use AppContext->viewer instead of is_user_logged_in() to check auth state
- AppContext->viewer is set at Request creation and doesn't change mid-request
- Cache the is_object_cache_enabled() result per-request for consistency
- Add Cache-Control: no-store header for authenticated requests

See: wp-graphql/wp-graphql-jwt-authentication#38
WPGraphQL core calls wp_set_current_user(0) mid-request when no nonce is
present (in has_authentication_errors), which caused is_user_logged_in()
to return false even for authenticated requests. This allowed sensitive
data (e.g., draft posts) to be cached and subsequently served to
unauthenticated users.

The fix:
- Use AppContext->viewer->exists() instead of is_user_logged_in() to
  determine authentication state. AppContext->viewer is set once at
  Request creation and remains stable throughout the request lifecycle.
- Include user ID in cache keys to ensure authenticated and
  unauthenticated requests produce separate cache entries.
- Add Cache-Control: no-store header for authenticated requests to
  prevent network caches (CDN, Varnish) from caching sensitive responses.

Includes wpunit and acceptance tests to prevent regression.
- Add second authenticated request to verify cache remains unpopulated
  across multiple auth requests
- Add test step to verify authenticated users bypass existing cache
  entries populated by public requests
- Improve test documentation with step-by-step comments

These tests comprehensively validate that:
1. Authenticated requests never populate the object cache
2. Authenticated requests never read from the object cache
3. Public requests correctly use the cache
4. Complete isolation exists between auth and public caching
- Pin behat/gherkin to < 4.9 to fix i18n.php path issue with Codeception 4.x
- Add gherkin: [] to codeception.dist.yml as extra safeguard
- Update deprecated GitHub Actions to v4
- Fix deprecated set-output syntax
@jasonbahl jasonbahl merged commit f911a0c into main Dec 12, 2025
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants