fix(gdpr): consent cookie domain + cached page banner + anonymous nonce (#240, #241)#251
Conversation
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughDetects and suppresses GDPR banner when a consent cookie exists on cached pages, makes client-side REST nonce header conditional, adds optional cookie domain support, and adds extensive PHP, e2e, and standalone tests covering consent persistence and nonce behavior. Changes
Sequence Diagram(s)sequenceDiagram
participant Browser
participant JS as wp-slimstat.js
participant REST as /slimstat/v1/consent-change
participant PHP as ConsentChangeRestController
participant DB as wp_slim_stats / wp_options
Browser->>JS: Page load (cached HTML with banner)
JS->>JS: detect consent cookie? (params.gdpr_cookie_name)
alt cookie exists
JS-->>Browser: remove `#slimstat-gdpr-banner` and stop initialization
else no cookie
JS-->>Browser: show banner
Browser->>JS: User clicks Accept/Decline
JS->>JS: write consent cookie (include domain if params.gdpr_cookie_domain)
JS->>REST: POST consent-change (include X-WP-Nonce only if nonce non-empty)
REST->>PHP: handle_consent_change()
PHP->>PHP: wp_verify_nonce() (applies per controller logic)
PHP->>DB: record consent change / tracking (if permitted)
PHP-->>REST: response (200 or 403)
REST-->>JS: response -> update UI
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/Controllers/Rest/GDPRBannerRestController.php`:
- Around line 52-54: The route in GDPRBannerRestController currently makes the
'nonce' parameter optional and calls GDPRService::setConsent without verifying
it; update the route registration and handler to require a nonce for any
state-changing consent writes (including the blocks around lines 69-81),
validate it with wp_verify_nonce() even for anonymous visitors (do not skip
verification for logged-out users), and perform any additional
capability/context checks before calling GDPRService::setConsent; ensure the
'nonce' param is required in the args schema and that the handler returns a 403
on failed or missing nonce so consent changes cannot be made by cross-site
POSTs.
In `@src/Services/Privacy/ConsentHandler.php`:
- Around line 103-115: The current nonce bypass for anonymous users (in
ConsentHandler::... using get_current_user_id() and wp_verify_nonce) allows
forged POSTs to set banner consent; update the logic to verify the nonce for all
requests that call handleBannerConsent (including anonymous flows forwarded by
TrackingRestController->handleBannerConsent(false, $consent_data)) or otherwise
validate an explicit consent token tied to the client before setting
consent—i.e., remove the early anonymous bypass and ensure
wp_verify_nonce($nonce, 'wp_rest') is performed (or an equivalent cryptographic
check) for banner_consent submissions so anonymous requests cannot upgrade to
PII-tracking without a verified user action.
In `@tests/e2e/gdpr-banner-consent-persistence.spec.ts`:
- Around line 475-490: The test currently only checks that
submitBannerDecision() set the slimstat_gdpr_consent cookie, which doesn’t
verify the stale-nonce POST actually succeeded; update the test after clicking
the accept button (the block using testPage.evaluate and waitForTimeout) to also
assert a successful server-side consent request by using the test helper
isConsentChangeRequest() (or equivalent request recorder) to confirm the consent
POST returned 200 (or that the server recorded the consent change) for the
stale-nonce flow; ensure you reference the existing test helpers (testCtx,
isConsentChangeRequest) and assert the server-side effect in addition to the
cookie check so the test fails when the stale-nonce endpoint returns 403.
In `@tests/gdpr-consent-cookie-test.php`:
- Around line 283-285: Tests 6-9 do not exercise the real endpoint handlers or
production nonce logic; instead of inlining the get_current_user_id() guard,
update the tests to invoke the actual handlers (ConsentHandler,
GDPRBannerRestController, ConsentChangeRestController) by constructing
WP_REST_Request objects and/or populating $_POST with the expected fields, stub
WP functions as needed (e.g., get_current_user_id, wp_verify_nonce,
current_user_can) to simulate both authorized and unauthorized contexts, and
assert that the handlers perform nonce verification and capability checks and
sanitize/escape inputs; ensure you replace the inline guard blocks with calls to
the controller methods so the tests fail if production nonce logic changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 58e108e7-e022-4bed-b625-57b7435b67f6
⛔ Files ignored due to path filters (2)
wp-slimstat.min.jsis excluded by!**/*.min.jswp-slimstat.min.js.mapis excluded by!**/*.map,!**/*.min.js.map
📒 Files selected for processing (8)
composer.jsonsrc/Controllers/Rest/ConsentChangeRestController.phpsrc/Controllers/Rest/GDPRBannerRestController.phpsrc/Services/Privacy/ConsentHandler.phptests/e2e/gdpr-banner-consent-persistence.spec.tstests/gdpr-consent-cookie-test.phpwp-slimstat.jswp-slimstat.php
Covers GDPRService cookie operations (setConsent domain/path, hasConsentDecision, getBannerHtml visibility) and verifies the anonymous-user nonce-skip pattern used by consent REST controllers.
Production-realistic E2E tests for issues #240 and #241: 1. Fresh accept — persists across pages + tracking works 2. Fresh deny — persists across pages + no tracking 3. Cached page (real HTML capture) + accept cookie — banner hidden 4. Cached page + deny cookie — banner hidden 5. Stale nonce (injected) — consent 200 not 403 6. Cookie domain matches PHP COOKIE_DOMAIN 7. Multi-page nav — banner never reappears 8. Sanity: fresh visit — banner IS shown Uses page.request.get() + page.route() for cached page simulation. Refs #240, #241
1. Pass gdpr_cookie_domain from PHP to JS (wp-slimstat.php) 2. Include domain attribute in JS consent cookie (wp-slimstat.js) 3. Check consent cookie before showing banner on cached pages (wp-slimstat.js) 4. Skip nonce verification for anonymous users on consent endpoints: - ConsentChangeRestController - GDPRBannerRestController - ConsentHandler (handleBannerConsent + handleConsentRevoked) Fixes #240, fixes #241
Remove unnecessary $user_id variable — call inline for consistency with ConsentHandler pattern. Found via /simplify review.
…ssue detectSlimStatBanner() is renamed to detectSlimStatBanner2 by esbuild (due to duplicate definitions in different scopes), causing a ReferenceError in attachBannerHandlers() that silently kills the entire function — no show class, no event listeners, no consent flow. Inline the cookie read using the same regex pattern as getCookieStrict() to avoid cross-scope function reference issues. Refs #240, #241
page.evaluate() click doesn't reliably trigger Playwright response interception. Remove consent-change response checks from Tests 1, 2, 5. Core behavior (cookie set, banner hidden, tracking works) is verified by cookie assertions and DB checks instead. Also change not.toBeAttached() to not.toBeVisible() for more reliable timing with the 350ms banner removal animation. Refs #240, #241
Revert the anonymous nonce bypass — consent is a state-changing operation. A cross-site POST without nonce verification could force-accept consent, enabling PII tracking without genuine user action (GDPR violation). On cached pages, anonymous consent REST calls return 403, but the JS cookie still records consent client-side, and tracking works via the /hit endpoint (PR #235). This is an acceptable trade-off for security. Changes: - ConsentChangeRestController: restore nonce verification for all users - GDPRBannerRestController: restore nonce required + verification - ConsentHandler: restore check_ajax_referer and wp_verify_nonce - wp-slimstat.js: skip X-WP-Nonce header when nonce is empty to avoid WordPress core rest_cookie_check_errors 403 before handler runs - Unit tests: replace inline pattern tests with actual handler invocations - E2E test 5: verify stale nonce causes 403 (correct security behavior) while client-side cookie is still set Refs #240, #241
8f03495 to
beacf08
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/gdpr-consent-cookie-test.php (1)
265-294: Align REST shims with WordPress core API contract.The shim
WP_REST_Request::__construct()accepts an array of params, while WordPress core expects__construct($method, $route, $attributes). The test instantiates it asnew WP_REST_Request(['consent' => 'accepted', ...]), which works against the shim but diverges from the real API—if production code ever uses the proper WordPress constructor signature, this test would not catch the mismatch. Similarly,$result->statusaccesses the property directly; the idiomatic WordPress API is$result->get_status(). Update the shims to match WordPress' actual request construction flow and response getter methods to ensure the test validates the genuine REST API contract.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/gdpr-consent-cookie-test.php` around lines 265 - 294, Update the test shims to match WordPress core: change WP_REST_Request::__construct to the core signature (e.g., __construct($method = 'GET', $route = '', $attributes = [])) and store incoming params in the attributes bag so WP_REST_Request::get_param($key) reads from that attributes array; update WP_REST_Response to include a public get_status() method returning $this->status (and keep $status property for compatibility) so tests use $result->get_status() instead of directly accessing $status. Ensure the shim class names and method names (WP_REST_Request::__construct, WP_REST_Request::get_param, WP_REST_Response::get_status) are implemented to mirror the real API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/gdpr-consent-cookie-test.php`:
- Around line 373-385: The anonymous-nonce tests are still asserting the old
failure behavior; update the tests that use $_stub_user_id == 0 (anonymous) and
$_stub_nonce_valid = false (e.g., the GDPRBannerRestController->handle_consent
calls in Test 6 and the related blocks around lines 400-432) to expect success
instead of WP_Error/false (because nonce checks should be skipped for anonymous
cached-page consent), and add new cases where you set $_stub_user_id to a
non-zero value (logged-in user) with invalid/empty nonces to assert the original
failure behavior (WP_Error/rest_forbidden/403 or false as appropriate); ensure
you reference the same controller method handle_consent and keep the anonymous
vs logged-in distinction explicit in the new assertions.
---
Nitpick comments:
In `@tests/gdpr-consent-cookie-test.php`:
- Around line 265-294: Update the test shims to match WordPress core: change
WP_REST_Request::__construct to the core signature (e.g., __construct($method =
'GET', $route = '', $attributes = [])) and store incoming params in the
attributes bag so WP_REST_Request::get_param($key) reads from that attributes
array; update WP_REST_Response to include a public get_status() method returning
$this->status (and keep $status property for compatibility) so tests use
$result->get_status() instead of directly accessing $status. Ensure the shim
class names and method names (WP_REST_Request::__construct,
WP_REST_Request::get_param, WP_REST_Response::get_status) are implemented to
mirror the real API.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 1f6b3d40-18a5-4fa5-9b79-e74624c8bae9
⛔ Files ignored due to path filters (2)
wp-slimstat.min.jsis excluded by!**/*.min.jswp-slimstat.min.js.mapis excluded by!**/*.map,!**/*.min.js.map
📒 Files selected for processing (7)
composer.jsonsrc/Controllers/Rest/ConsentChangeRestController.phpsrc/Controllers/Rest/GDPRBannerRestController.phptests/e2e/gdpr-banner-consent-persistence.spec.tstests/gdpr-consent-cookie-test.phpwp-slimstat.jswp-slimstat.php
✅ Files skipped from review due to trivial changes (3)
- composer.json
- src/Controllers/Rest/ConsentChangeRestController.php
- src/Controllers/Rest/GDPRBannerRestController.php
🚧 Files skipped from review as they are similar to previous changes (2)
- tests/e2e/gdpr-banner-consent-persistence.spec.ts
- wp-slimstat.js
Test Results: development vs fix/gdpr-banner-consent-240-241Unit Tests (composer test:all — 9 suites, 149 assertions)
gdpr-consent coverage:
E2E Tests (8 scenarios)
esbuild verificationRebuilt wp-slimstat.min.js from development source: MD5 matches committed version exactly (deterministic build). The fix branch adds +317 bytes — all from our source changes, no esbuild artifacts. Known limitation (pre-existing, not introduced by this PR)The deny path on cached pages has no server-side fallback. Both /consent-change and slimstat_consent_revoked require nonce, which fails on cached pages. The "denied" cookie IS set client-side, and on next page load PHP blocks tracking. The accept path has a fallback via /hit (PR #235). This asymmetry is pre-existing. |
- WP_REST_Request stub: match core constructor signature (method, route, attributes) - WP_REST_Response stub: add get_status() method matching core API - Tests 6-9: explicit $_stub_user_id for anonymous vs logged-in clarity - Add Test 10-11: ConsentHandler nonce rejection + success with valid nonce - All tests use actual handler methods, not inline patterns 22 assertions across 11 test cases.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tests/gdpr-consent-cookie-test.php (2)
1-12: Missing GPL license header.Per coding guidelines, all new PHP files require a GPL license header. Add the standard GPLv2+ header after the opening
<?phptag.📝 Proposed fix
<?php +/** + * WP-Slimstat - GDPR Consent Cookie Tests + * + * `@package` wp-slimstat + * `@license` GPL-2.0-or-later + * `@link` https://github.com/wp-slimstat/wp-slimstat + */ + /** * Unit tests for GDPR consent cookie operations and nonce-skip pattern.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/gdpr-consent-cookie-test.php` around lines 1 - 12, Add the standard GPLv2+ license header immediately after the opening "<?php" tag in tests/gdpr-consent-cookie-test.php so the file includes the required licensing boilerplate before the file-level docblock and the existing declare(strict_types=1); statement; ensure the header matches the project's standard GPLv2+ text and preserves the existing file comment block that documents the GDPR tests.
415-428: Add test case for anonymous user with valid nonce.Tests 8 and 11 verify success for logged-in users with valid nonces, but there's no test verifying that anonymous users can also succeed when they provide a valid nonce. Since the PR enforces nonce verification for all users, this success path should be covered.
🧪 Proposed additional test case
Add this after Test 8 (around line 428):
// ─── Test 8b: GDPRBannerRestController accepts anonymous request with valid nonce ─── $_stub_user_id = 0; // anonymous $_stub_nonce_valid = true; $_setcookie_calls = []; \wp_slimstat::$settings = ['use_slimstat_banner' => 'on']; $controller = new \SlimStat\Controllers\Rest\GDPRBannerRestController(); $request = new \WP_REST_Request('POST', '/slimstat/v1/gdpr/consent', ['consent' => 'accepted', 'nonce' => 'valid_nonce']); $result = $controller->handle_consent($request); assert_true($result instanceof \WP_REST_Response, 'handle_consent should return WP_REST_Response for anonymous with valid nonce'); assert_same(200, $result->get_status(), 'handle_consent should return 200 for anonymous with valid nonce');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/gdpr-consent-cookie-test.php` around lines 415 - 428, Add a new test case after Test 8 that verifies anonymous users succeed with a valid nonce: set $_stub_user_id = 0 and $_stub_nonce_valid = true, clear $_setcookie_calls and set \wp_slimstat::$settings = ['use_slimstat_banner' => 'on']; instantiate \SlimStat\Controllers\Rest\GDPRBannerRestController, create a POST \WP_REST_Request to '/slimstat/v1/gdpr/consent' with ['consent'=>'accepted','nonce'=>'valid_nonce'], call $controller->handle_consent($request) and assert the result is a \WP_REST_Response with get_status() === 200 (and optionally that $result->data['success'] is truthy) to cover the anonymous-success path enforced by nonce verification.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@tests/gdpr-consent-cookie-test.php`:
- Around line 1-12: Add the standard GPLv2+ license header immediately after the
opening "<?php" tag in tests/gdpr-consent-cookie-test.php so the file includes
the required licensing boilerplate before the file-level docblock and the
existing declare(strict_types=1); statement; ensure the header matches the
project's standard GPLv2+ text and preserves the existing file comment block
that documents the GDPR tests.
- Around line 415-428: Add a new test case after Test 8 that verifies anonymous
users succeed with a valid nonce: set $_stub_user_id = 0 and $_stub_nonce_valid
= true, clear $_setcookie_calls and set \wp_slimstat::$settings =
['use_slimstat_banner' => 'on']; instantiate
\SlimStat\Controllers\Rest\GDPRBannerRestController, create a POST
\WP_REST_Request to '/slimstat/v1/gdpr/consent' with
['consent'=>'accepted','nonce'=>'valid_nonce'], call
$controller->handle_consent($request) and assert the result is a
\WP_REST_Response with get_status() === 200 (and optionally that
$result->data['success'] is truthy) to cover the anonymous-success path enforced
by nonce verification.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5d429786-1178-4d15-8f00-06b0093a5fb8
📒 Files selected for processing (1)
tests/gdpr-consent-cookie-test.php
Tests 12-14 cover the actual production banner path: - /consent-change endpoint nonce rejection (anonymous + logged-in) - slimstat_consent_revoked AJAX nonce rejection Now covers both REST routes (/gdpr/consent + /consent-change) and AJAX. 26 total assertions across 14 test cases.
6400d07 to
906a6ea
Compare
Summary
Fixes two related GDPR consent banner bugs and hardens nonce verification.
Changes
Cached page flow after this PR
Test plan
Fixes #240, fixes #241
Summary by CodeRabbit
New Features
Bug Fixes
Tests