Skip to content

Instantly share code, notes, and snippets.

@zhongruige
Last active May 5, 2026 21:21
Show Gist options
  • Select an option

  • Save zhongruige/a69143882a252ef44f106b5921fdd275 to your computer and use it in GitHub Desktop.

Select an option

Save zhongruige/a69143882a252ef44f106b5921fdd275 to your computer and use it in GitHub Desktop.
2026-05-04T17-48-11_jetpack

Testing Report — jetpack

Run ID: 2026-05-05T20-17-23_jetpack Generated: 2026-05-05T21:19:28.392Z Plugin version: 15.7.1 Sessions processed: 9 Sessions with errors: 1


Executive summary

Category Count
Problems 21
Questions 15
Improvements 11
Praises 19

Problem severity breakdown

Severity Count
critical 0
major 7
minor 14
trivial 0

Severity heatmap by area

Area Critical Major Minor Trivial Risk score
WordAds GDPR Consent Management 0 1 2 0 7
AJAX handler access control — Carousel (F7) 0 1 1 0 5
Jetpack admin navigation / my-jetpack 0 1 0 0 3
Custom Post Types (F14) 0 1 0 0 3
Module interdependency / Admin access control 0 1 0 0 3
AJAX handler access control — Sharing (F6) 0 1 0 0 3
Service API keys REST endpoint 0 1 0 0 3
Jetpack Settings SPA routing 0 0 1 0 2
Jetpack Settings / Account Protection module 0 0 1 0 2
Shortcodes (F8) 0 0 1 0 2
Sitemaps (F12) 0 0 1 0 2
Cookie Consent (F34) 0 0 1 0 2
Module interdependency / Settings surface consistency 0 0 1 0 2
Module lifecycle — activation/deactivation 0 0 1 0 2
Module lifecycle — cron cleanup 0 0 1 0 2
Module lifecycle — uninstall blast radius (H17) 0 0 1 0 2
Premium Content Block / Subscription Service 0 0 1 0 2
Sharing buttons — admin settings page 0 0 1 0 2
Jetpack Markdown module 0 0 0 0 0
Jetpack Post by Email 0 0 0 0 0
Jetpack admin footer / my-jetpack link 0 0 0 0 0
Jetpack Settings / offline-mode modules 0 0 0 0 0
Jetpack REST API security 0 0 0 0 0
Jetpack admin capability gating 0 0 0 0 0
Jetpack dashboard offline mode UX 0 0 0 0 0
Infinite Scroll (F18) 0 0 0 0 0
Widgets (F9) 0 0 0 0 0
Admin / Jetpack React UI 0 0 0 0 0
Custom Content Types (F14) 0 0 0 0 0
Contact Form block (F4/F32) 0 0 0 0 0
Shortcodes — YouTube/Vimeo (F8) 0 0 0 0 0
Contact Forms — form submission 0 0 0 0 0
Contact Forms — inbox UI 0 0 0 0 0
Contact Forms — form submission validation 0 0 0 0 0
Contact Forms — XSS sanitization 0 0 0 0 0
Contact Forms — form validation UX 0 0 0 0 0
Contact Forms — submission confirmation 0 0 0 0 0
Sharing module state / React SPA toggle consistency 0 0 0 0 0
Recon S2 — Version string discrepancy 0 0 0 0 0
Post by Email offline mode UX 0 0 0 0 0
Module state synchronization 0 0 0 0 0
Module lifecycle — uninstall 0 0 0 0 0
Module lifecycle — sitemaps 0 0 0 0 0
Module lifecycle — capability and nonce verification 0 0 0 0 0
Module lifecycle — partial deactivation consistency 0 0 0 0 0
Sharing global options sanitization 0 0 0 0 0
Custom sharing service output escaping 0 0 0 0 0
Sharing buttons — configuration and frontend rendering 0 0 0 0 0
Sharing buttons — settings persistence 0 0 0 0 0
Sharing buttons — button style live preview 0 0 0 0 0

Risk score = 4·critical + 3·major + 2·minor + 1·trivial

Top problems

1. [MAJOR] Mapbox API key exposed to unauthenticated visitors via REST endpoint with __return_true permission_callback

  • Area: Service API keys REST endpoint
  • Persona affected: admin
  • Confidence: 1
  • Session: security-input-output

Steps to reproduce:

  1. Configure a Mapbox API key in Jetpack settings (or set via WP-CLI: studio wp option update jetpack_mapbox_api_key 'pk.yourkey')
  2. Without any authentication cookies or headers, send: GET /wp-json/wpcom/v2/service-api-keys/mapbox
  3. Observe HTTP 200 response containing the configured API key in service_api_key field

Expected: The GET endpoint should require at minimum 'read' capability or equivalent authentication. API keys should not be readable by unauthenticated visitors.

Actual: HTTP 200 is returned with the full API key value: {"code":"success","service":"mapbox","service_api_key":"pk.testkey12345678","service_api_key_source":"site","message":"API key retrieved successfully."}. The permission_callback is '__return_true', meaning no authentication is checked.

Evidence: · console

Notes: The source at inc/lib/core-api/wpcom-endpoints/service-api-keys.php:51 explicitly sets permission_callback => '__return_true' for the READABLE method. The UPDATE and DELETE methods correctly use edit_others_posts_check, but GET is unprotected. While Mapbox public tokens (pk.*) are nominally public, the pattern creates an unauthenticated disclosure endpoint for any future service keys added to the same handler. The endpoint also accepts arbitrary service slugs matching [a-z-]+ which would probe any Jetpack option name constructed via key_for_api_service().

2. [MAJOR] Sharing AJAX handlers (4 endpoints) perform no capability check — any logged-in user can modify site-wide sharing configuration

  • Area: AJAX handler access control — Sharing (F6)
  • Persona affected: admin
  • Confidence: 0.95
  • Session: security-ajax-capability

Steps to reproduce:

    1. Log in as a Subscriber-level user
    1. Generate a valid WordPress nonce for the 'sharing-options' action (e.g., via wp_create_nonce('sharing-options') in the subscriber's session context)
    1. POST to /wp-admin/admin-ajax.php with: action=sharing_save_services, _wpnonce=<subscriber_nonce>, visible=print,email, hidden=
    1. Observe response: '0' (success path, die(0) in ajax_save_services())
    1. Confirm via WP-CLI: sharing-services option now shows the services specified by the subscriber
    1. Repeat for sharing_new_service (creates custom service), sharing_delete_service (deletes service), sharing_save_options (modifies service config) — all vulnerable

Expected: AJAX handlers that modify site-wide configuration should verify current_user_can('manage_options') before executing. A Subscriber-level POST should return a 403/error response.

Actual: All four sharing AJAX handlers (ajax_save_services, ajax_new_service, ajax_delete_service, ajax_save_options in modules/sharedaddy/sharing.php:149-248) verify nonces but never call current_user_can(). A Subscriber with a valid nonce for the correct action can modify sharing configuration. The only current_user_can('manage_options') check in the file (line 371) guards UI display, not the AJAX handlers.

Evidence: · console

Notes: Source evidence is unambiguous: grep of modules/sharedaddy/sharing.php shows 4 wp_ajax_ registrations at lines 46-49, all four handler functions (lines 149-248) check nonces but have no current_user_can() calls. WP-CLI simulation confirmed: subscriber nonce verifies (wp_verify_nonce result=1), subscriber lacks manage_options (false), but set_blog_services() still executes and writes the option. The practical exploitability requires the attacker to first obtain a valid 'sharing-options' nonce — this nonce is normally only generated on the sharing admin page (which subscribers cannot access). However, the nonce could be obtained via: stored XSS on another admin page, phishing an admin to visit a page that triggers the AJAX call, or future changes to where nonces are generated. The missing current_user_can() is a clear code defect. Sibling confirmed: sharing_new_service also creates a custom service when invoked as subscriber.

3. [MAJOR] GDPR consent AJAX handler (gdpr_set_consent) accepts unauthenticated POST requests with no CSRF/nonce protection

  • Area: WordAds GDPR Consent Management
  • Persona affected: guest
  • Confidence: 0.95
  • Session: security-cookie-consent

Steps to reproduce:

  1. Ensure Jetpack is installed and WordAds module is active (requires WP.com connection on production)
  2. Without any authentication or nonce, POST to /wp-admin/admin-ajax.php with body: action=gdpr_set_consent&consent=arbitrary-value
  3. Observe HTTP 200 response: {"success":true,"data":true}
  4. Observe Set-Cookie: euconsent-v2=arbitrary-value in the response headers

Expected: State-changing AJAX endpoint should verify a nonce to prevent CSRF. Unauthenticated requests that modify persistent state (cookies lasting 1 year) should have CSRF protection.

Actual: The handler accepts any POST from any origin with no nonce verification, returning success and setting a year-long cookie. Source code has phpcs:disable WordPress.Security.NonceVerification.Missing explicitly disabling the lint check. The handler is registered as wp_ajax_nopriv_gdpr_set_consent, making it accessible to all visitors and automated scripts.

Evidence: console

Notes: Per security-exploration/SKILL.md, missing nonce on state-changing operation = major (CSRF). The handler is a nopriv (unauthenticated) AJAX action that sets a persistent cookie. In a real attack, any third-party site can silently reset or poison the GDPR consent of site visitors by making a cross-origin POST. This bypasses the IAB TCF consent framework for all visitors. The TODO comment in the source ('TODO: Is there better sanitizing we can do here?') indicates the author acknowledged this gap. Tested via mu-plugin forcing the handler registration — the code path is identical to what runs on a WP.com-connected site.

4. [MAJOR] my-jetpack page returns 'Sorry, you are not allowed to access this page' for admin users in offline mode

  • Area: Module interdependency / Admin access control
  • Persona affected: admin
  • Confidence: 0.9
  • Session: module-interdependency-seams

Steps to reproduce:

  1. Log in as admin on a site in Jetpack Offline Mode (e.g. localhost)
  2. Navigate to /wp-admin/admin.php?page=my-jetpack
  3. Observe the page response

Expected: Admin users should be able to access the my-jetpack page even in offline mode, or should see a graceful 'offline mode' explanation

Actual: The page shows 'WordPress Error: Sorry, you are not allowed to access this page.' — a generic WordPress capability-check failure. The browser console shows a 403 Forbidden error for the page.

Evidence: · console

Notes: The Settings > Sharing tab has links pointing to http://localhost:8887/wp-admin/admin.php?page=my-jetpack#/add-scan (Firewall Upgrade) and Upgrade flows that navigate to my-jetpack. If admin users cannot access my-jetpack, those links are dead ends. This is recon S1 confirmed. The 403 suggests the my-jetpack page's capability check fails when Jetpack is in offline mode — the page may require a valid WP.com connection before rendering.

5. [MAJOR] admin.php?page=my-jetpack returns 403 Forbidden for admin user — admin cannot access their own plugin management page

  • Area: Jetpack admin navigation / my-jetpack
  • Persona affected: admin
  • Confidence: 0.85
  • Session: breadth-tour-admin

Steps to reproduce:

  1. Log in as admin
  2. Navigate to wp-admin/admin.php?page=my-jetpack (or click any footer link pointing to my-jetpack)

Expected: Admin user can access the My Jetpack management page, which shows installed Jetpack products and upgrade options

Actual: Page title becomes 'WordPress › Error' and body shows 'Sorry, you are not allowed to access this page.' Console shows 403 Forbidden response. This occurs in offline mode — my-jetpack may require WP.com connection for its capability check.

Evidence: · [console](sessions/breadth-tour-admin/console-logs.txt — [ERROR] Failed to load resource)

Notes: This is likely an intentional restriction in offline mode — my-jetpack requires WP.com connection. However, the footer of the Jetpack dashboard links to my-jetpack, creating a broken link experience for admins in offline mode. The capability check should either (a) not be tied to WP.com connection status, or (b) the footer links should be hidden/disabled in offline mode.

6. [MAJOR] Custom Content Types module active but Portfolio and Testimonial CPTs not registered

  • Area: Custom Post Types (F14)
  • Persona affected: admin
  • Confidence: 0.8
  • Session: breadth-tour-frontend

Steps to reproduce:

  1. Add 'custom-content-types' to jetpack_active_modules
  2. Flush rewrite rules
  3. Visit /portfolio/ or /testimonial/ on the frontend
  4. Check registered post types with WP-CLI: wp post-type list

Expected: jetpack-portfolio and jetpack-testimonial post types should be registered, admin menu items should appear, and /portfolio/ archive should return a valid (empty) page

Actual: Both /portfolio/ and /testimonial/ return HTTP 404. WP-CLI post-type list shows neither CPT is registered. Source inspection shows the CPT code checks for class Automattic\Jetpack\Classic_Theme_Helper\Jetpack_Portfolio which is absent from this installation.

Evidence: · console

Notes: The custom-content-types.php module has been refactored in Jetpack 13.9 to depend on the Classic Theme Helper package (Automattic\Jetpack\Classic_Theme_Helper\Jetpack_Portfolio). If this package is not installed, the CPTs silently fail to register. This is a regression or package dependency issue. The module is shown as active but provides no functionality.

7. [MAJOR] Carousel comment handler registered nopriv — unauthenticated visitors can submit comments to any attachment post

  • Area: AJAX handler access control — Carousel (F7)
  • Persona affected: guest
  • Confidence: 0.8
  • Session: security-ajax-capability

Steps to reproduce:

    1. Activate Jetpack carousel module
    1. Navigate to any post with an image gallery as a logged-out (unauthenticated) visitor
    1. Extract carousel_nonce from the page's JS globals (jetpackCarouselStrings.nonce) — it is embedded in the public frontend page source
    1. POST to /wp-admin/admin-ajax.php with: action=post_attachment_comment, nonce=<carousel_nonce>, blog_id=1, id=<attachment_post_id>, comment=Test comment, author=Anonymous, email=[email protected]
    1. Observe response: {comment_id: N, comment_status: unapproved}

Expected: The carousel_nonce is a public nonce embedded in the page for unauthenticated users. The handler allows unauthenticated comment submission by design. However, there is no Jetpack-level rate limiting — WordPress core's comment flood protection is the only gate. The concern is the attack surface: any unauthenticated visitor who can read the page source can submit comments at whatever rate WP core permits.

Actual: wp_ajax_nopriv_post_attachment_comment registered at jetpack-carousel.php:106. POST with the publicly available carousel_nonce (generated for user_id=0, embedded in frontend JS) successfully created comment ID 2: author='Anonymous Tester', content='Test unauthenticated carousel comment', post_id=4, approved=0. The nonce is accessible to any page visitor without authentication.

Evidence: · console

Notes: This may be by design — the carousel is meant to allow comments from gallery viewers, and WordPress's comment system supports unauthenticated comments when comment_registration=0. However: (1) the nonce is a weak gate since it's publicly embedded in page source, (2) there is no Jetpack-level rate limiting on the AJAX handler — only WP core comment flood protection, (3) the email field is not sanitized by the handler (raw wp_unslash() without sanitize_email() before passing to wp_new_comment), (4) the comment goes directly to wp_new_comment which may silently succeed even when comments are closed on the parent post if the attachment post has comments open. Severity could be downgraded if this behavior is intentional and documented.

8. [MINOR] Premium Content JWT session cookie (wp-jp-premium-content-session) is not HttpOnly — readable by JavaScript

  • Area: Premium Content Block / Subscription Service
  • Persona affected: customer
  • Confidence: 1
  • Session: security-cookie-consent

Steps to reproduce:

  1. Visit any page on a Jetpack site with the Premium Content block active
  2. Append ?token= to the URL (e.g., /?token=header.payload.signature)
  3. The cookie wp-jp-premium-content-session is set server-side
  4. Evaluate document.cookie in browser console
  5. Observe: the JWT token value appears in document.cookie

Expected: Authentication/session tokens should use HttpOnly=true to prevent JavaScript from reading the cookie. This is the standard defense against XSS-based session hijacking.

Actual: The cookie is set with HttpOnly=false (last parameter to setcookie). The phpcs:ignore comment in the source states 'Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse', confirming this is an intentional bypass of the linting rule. The JWT token is confirmed readable via document.cookie.

Evidence: · console

Notes: Per security-exploration/SKILL.md: 'Missing HttpOnly/Secure on plugin-set cookies = minor (defense-in-depth gap; not directly exploitable without other vulnerability).' The JWT token cookie being JavaScript-readable means that if ANY XSS vulnerability exists elsewhere in Jetpack or WordPress, attackers can steal the premium content access token and use it to access gated content. Severity would escalate to major if a confirmed XSS vector in Jetpack were found simultaneously. The 'Client side CMP needs to be able to read this value' justification applies to the GDPR consent cookie (H6) but NOT to the JWT session token — a JWT auth token has no legitimate client-side read requirement.

9. [MINOR] Sitemaps module deactivation leaves jp_sitemap_cron_hook orphaned in WP-Cron schedule

  • Area: Module lifecycle — cron cleanup
  • Persona affected: admin
  • Confidence: 0.95
  • Session: module-lifecycle-destructive

Steps to reproduce:

  1. Log in as admin
  2. Navigate to /wp-admin/admin.php?page=jetpack_modules
  3. Ensure Sitemaps module is active (verify jp_sitemap_cron_hook is scheduled via WP-CLI: studio wp cron event list)
  4. Click 'Deactivate' next to the Sitemaps module
  5. Immediately check cron: studio wp cron event list | grep sitemap
  6. Observe: jp_sitemap_cron_hook is still present in the schedule

Expected: When the Sitemaps module is deactivated, the jp_sitemap_cron_hook cron event should be cleared (just as it is cleared on module activation via jetpack_sitemap_on_activate)

Actual: jp_sitemap_cron_hook remains in the WP-Cron schedule after sitemaps module deactivation. The sitemaps.php module file only hooks on 'jetpack_activate_module_sitemaps' to call wp_clear_scheduled_hook('jp_sitemap_cron_hook'), but there is no corresponding 'jetpack_deactivate_module_sitemaps' hook. The orphaned cron will fire at its next scheduled time (up to 12 hours later) and silently fail since the module code is no longer loaded.

Evidence: · console

Notes: Root cause: modules/sitemaps.php line 30 has add_action('jetpack_activate_module_sitemaps', 'jetpack_sitemap_on_activate') which clears the cron hook. No matching deactivate hook exists anywhere in sitemaps.php or sitemaps/sitemaps.php. The orphaned cron event will fire once (Non-repeating after the first missed recurrence), but its callback won't be registered since the module is inactive, so it fails silently. In the long run, this is self-resolving (the event disappears after firing once), but it's still unexpected behavior and represents a resource waste.

10. [MINOR] Settings hash route #/monetize redirects to #/dashboard instead of showing Monetize settings tab

  • Area: Jetpack Settings SPA routing
  • Persona affected: admin
  • Confidence: 0.9
  • Session: breadth-tour-admin

Steps to reproduce:

  1. Log in as admin
  2. Navigate to wp-admin/admin.php?page=jetpack#/monetize

Expected: A Monetize settings tab renders with monetization-related settings (Ads, memberships, etc.)

Actual: Page redirects to #/dashboard URL and shows the Jetpack dashboard instead of any settings content. The Settings page tab list shows 'Monetize' as a tab option, but the route is unimplemented in the SPA.

Evidence: [console](sessions/breadth-tour-admin/console-logs.txt — no JS errors on redirect)

Notes: The Settings tabs show 8 tabs including 'Monetize', but direct navigation to #/monetize falls back to #/dashboard. This is either an unimplemented route that should show something, or the Monetize tab should be removed from the Settings navigation when offline/unconnected.

Needs human review (confidence < 0.7)

None.

Questions raised

  • [Jetpack Markdown module] Does the Markdown module correctly process Markdown syntax in post content on the frontend? The module is active, but a post created via WP-CLI with Markdown syntax and apply_filters('the_content') via WP-CLI both showed raw Markdown rather than rendered HTML.
    • Why it matters: If Markdown rendering is broken, users enabling the module and writing Markdown will see raw syntax on their live site
  • [Jetpack Post by Email] When Post by Email feature is disabled in offline mode, should the section show a more explicit 'Requires WordPress.com connection' message rather than just disabling the controls silently?
    • Why it matters: Users may not understand why the feature controls are greyed out without explicit context. Other sections show 'Unavailable in Offline Mode' labels, but Post by Email section just disables without labeling.
  • [Infinite Scroll (F18)] When Infinite Scroll is active but the theme does not declare add_theme_support('infinite-scroll'), should Jetpack show an admin notice or frontend message indicating theme support is required?
    • Why it matters: Currently Infinite Scroll silently does nothing — no error, no message. Users who activate the module without understanding the theme support requirement will see no feedback. A clear admin notice would improve usability.
  • [Sitemaps (F12)] Does the Jetpack Sitemaps module require a WP.com connection to register its cron events and generate sitemaps, or should it work in offline/non-connected mode?
    • Why it matters: If the sitemaps module requires a connection, it should show an admin notice when activated without a connection. Currently it activates silently without producing any sitemap.
  • [Widgets (F9)] Do Jetpack's legacy sidebar widgets (Social Icons, Authors, etc.) function correctly in the block widget editor (wp-admin/widgets.php) or Appearance > Widgets in classic themes?
    • Why it matters: The test environment uses a block theme that is not widget-aware. Jetpack widget registration was confirmed via WP-CLI, but actual widget display/configuration could not be UI-tested.
  • [Contact Forms — form submission] When a message field contains ONLY HTML/script content (e.g. '<script>alert(xss)</script>'), the server returns 'Message field is required.' error — is this the intended behavior? The sanitization stripping the entire field value to empty and then validating as required could confuse legitimate users who use HTML entities or special characters in their messages.
    • Why it matters: A user submitting a message like '[email protected]' (email in angle brackets) or '<3' (heart) would get a cryptic 'field is required' error when their field appears filled. This is a UX surprise that could prevent legitimate messages from being delivered.
  • [Contact Forms — inbox UI] The 'Forms' tab in the inbox shows '0 forms' and 'You're set up. No forms yet.' even though the contact form block on the page is working and receiving submissions visible in the 'Responses' tab. Is this expected? The Forms tab appears to be for 'saved form templates' separately from embedded block forms — but the UI distinction is not clear to users who might expect their page's form to appear here.
    • Why it matters: An admin seeing 'No forms yet' in the Forms tab might think the contact form is broken, when it's actually working and submissions are on the Responses tab. This could cause confusion and unnecessary troubleshooting.
  • [Sharing module state / React SPA toggle consistency] When sharedaddy (Sharing) module is deactivated from jetpack_active_modules, the React SPA Sharing settings tab still shows 'Add sharing buttons to your posts and pages' as [checked]. Is this checkbox reading a sub-option value independently of module activation state, or is it a stale UI state?
    • Why it matters: If the checkbox reflects module activation, it should be unchecked when the module is inactive. If it reflects a separate sub-option, the UI should indicate the module is off and the setting is moot. Either way, showing an active-looking checked toggle for a deactivated module is confusing.
  • [Recon S2 — Version string discrepancy] Recon reported seeing 'Jetpack 12.2-a.0' in the Modules page footer while other pages showed 'Version 15.7.1'. This discrepancy was not reproducible in this test run — both surfaces showed 'Version 15.7.1'. Was recon S2 a false positive, or does this occur with specific Jetpack versions or configuration?
    • Why it matters: If the version string discrepancy is real in production builds, it would indicate the Modules page footer uses a different version source than the plugin header, which could confuse support diagnostics.
  • [Module lifecycle — uninstall] Is the uninstall behavior for feedback (contact-form submission) CPT posts intentional? Feedback posts are user-submitted content and may be intentionally preserved on uninstall (unlike sitemap cache posts which are disposable). If so, the charter's H17 concern about feedback CPT applies to sitemap CPT posts only.
    • Why it matters: The severity classification of the uninstall blast radius issue depends on whether contact-form feedback post preservation is intentional design or an oversight
  • [AJAX handler access control — Sharing (F6)] Is the practical exploitability of H1 limited enough that severity should be reduced? The 'sharing-options' nonce is only generated on the admin sharing page, which requires manage_options. Can a subscriber obtain this nonce through any other legitimate code path in Jetpack?
    • Why it matters: If there is no code path that generates the 'sharing-options' nonce for a non-admin user, the practical exploitability is limited to scenarios where an admin is compromised (XSS, etc.). This is still a defect but severity might be 'minor' rather than 'major' in a strict reading.
  • [AJAX handler access control — Carousel (F7)] Does the carousel comment handler at jetpack-carousel.php:1316 correctly check 'comments_open($post_id)' for the attachment post, or only for the parent post? Attachment posts (image posts) may have comments open even when the parent gallery post does not.
    • Why it matters: If comments_open() checks the attachment post's own status (not the parent), an attacker could target attachments directly to bypass comment moderation on the parent post.
  • [WordAds GDPR Consent Management] Is the euconsent-v2 cookie value ever read server-side and echoed to page output in any WordPress.com-specific code that's not in the open-source Jetpack plugin source?
    • Why it matters: If any WPCOM-specific code (mu-plugins, reader, etc.) reads $_COOKIE['euconsent-v2'] and echoes it unescaped, the stored XSS chain (H4) would be complete and severity would escalate to critical for any visitor who can be induced to visit such a page.
  • [Premium Content Block / Subscription Service] Is the JWT token stored in wp-jp-premium-content-session ever validated cryptographically server-side, or is it treated as a trusted capability grant?
    • Why it matters: The ?token= URL parameter allows any visitor to inject a JWT-format token that gets stored in the session cookie. If the server validates the token's signature against a WPCOM-held private key, this is safe. If the validation is weak or the token is accepted on format alone, it could allow unauthorized access to premium content.
  • [Service API keys REST endpoint] Is the Mapbox public token (pk.*) intentionally exposed without authentication because it is considered public by Mapbox's design, or is the __return_true permission_callback an oversight that could expose other service keys if added to the same endpoint in the future?
    • Why it matters: The current risk is limited to Mapbox tokens (which are designed to be public-facing), but the endpoint pattern is extensible. If a future Jetpack version adds a private API service key under the same endpoint, it would be exposed without any code change to the permission_callback.

Suggested improvements

  • [Jetpack admin footer / my-jetpack link] Hide or disable the 'My Jetpack' footer link when the site is in offline mode, since the page returns 403 for all users in that state. (effort: low) (impact: medium)
    • Rationale: Currently the footer shows a non-functional link that leads to a confusing error page. Either show an inline tooltip explaining WP.com connection is required, or remove the link entirely in offline mode.
  • [Jetpack Settings / offline-mode modules] Apply consistent disabled state to offline-mode-gated module toggles in the Settings UI — use the same visual treatment (grayed out, non-clickable, with 'Offline mode' or 'Requires WP.com' label) that is used for toggles like Downtime monitoring. (effort: medium) (impact: medium)
    • Rationale: Account Protection appears clickable but fails with 400 error — false affordance. The Modules page correctly shows Account Protection as 'Offline mode' but the Settings page shows it as active. Consistent visual treatment prevents user confusion and unnecessary failed API calls.
  • [Admin / Jetpack React UI] Fix React Router navigation() call outside useEffect() in Jetpack admin JS (effort: low) (impact: low)
    • Rationale: React logs a warning: 'You should call navigate() in a React.useEffect(), not when your component is first rendered' from admin.js. While non-breaking, this indicates a React best-practice violation that could cause subtle rendering issues in future React versions.
  • [Custom Content Types (F14)] Show an admin notice when the custom-content-types module is active but the Classic Theme Helper package is missing (effort: low) (impact: medium)
    • Rationale: The CPT module silently fails when its dependency (Classic Theme Helper package) is absent. An admin notice would help site administrators understand why portfolios/testimonials are not appearing.
  • [Contact Forms — form submission validation] When HTML/special characters in a form field cause the field to become empty after sanitization, show a more specific validation error such as 'Message contains unsupported characters. Please use plain text.' instead of the generic 'This field is required.' error. (effort: low) (impact: medium)
    • Rationale: The current behavior ('field is required' when field has content) is confusing and doesn't help users understand why their input was rejected or how to fix it. This would particularly affect users who naturally include HTML-like syntax (angle brackets for quoting, email addresses, etc.) in their messages.
  • [Post by Email offline mode UX] The 'Post by Email' section on the Writing settings tab renders fully (toggle, description, disabled 'Create address' button) even when the module is in 'Offline mode'. The Modules page simply shows 'Offline mode' next to the module name. The Settings tab should show a clear 'Offline mode' indicator to match the Modules page, rather than rendering all the controls in a disabled state without explanation. (effort: low) (impact: low)
    • Rationale: Users who see the disabled 'Create address' button without any 'Offline mode' label on the Writing tab won't understand why it's disabled — they may think they need to configure something. The Modules page is clearer about the reason.
  • [Module lifecycle — sitemaps] Add add_action('jetpack_deactivate_module_sitemaps', function() { wp_clear_scheduled_hook('jp_sitemap_cron_hook'); }) to modules/sitemaps.php to mirror the activation cleanup (effort: low) (impact: low)
    • Rationale: The activation hook already calls wp_clear_scheduled_hook — adding the same call on deactivation is a one-line fix that ensures consistent cron lifecycle management
  • [AJAX handler access control — Sharing (F6)] Add current_user_can('manage_options') check as the FIRST gate in each of the four sharing AJAX handlers: ajax_save_services, ajax_new_service, ajax_delete_service, ajax_save_options. Return wp_send_json_error('Unauthorized', 403) on failure. (effort: low) (impact: high)
    • Rationale: Defense in depth: capability check should gate before nonce verification to follow WordPress security best practices. Nonce proves intent (came from the right form), capability proves authorization (user can perform this action).
  • [AJAX handler access control — Carousel (F7)] Replace filter_var(wp_unslash($_POST['comment'])) with sanitize_text_field(wp_unslash($_POST['comment'])) at jetpack-carousel.php:1226 to provide meaningful input sanitization at the handler level rather than relying solely on wp_new_comment() downstream. (effort: low) (impact: medium)
    • Rationale: Defense in depth: explicit sanitization at the input boundary makes the code's intent clear and reduces risk if the downstream call is ever modified or bypassed.
  • [WordAds GDPR Consent Management] Validate the consent value against the IAB TCF consent string format (base64url-encoded binary) before storing it. Reject non-conforming values with a 400 error. (effort: low) (impact: medium)
    • Rationale: The IAB TCF specification defines a well-structured format for consent strings. Validating against it would prevent arbitrary injection of HTML/script content without breaking legitimate CMP functionality.
  • [Sharing buttons — admin settings page] Remove wp_enqueue_script('sharing-js-fe') from sharing_head() or guard it with !is_admin() — it is designed for frontend post rendering and is not needed on the admin settings page. The live preview section is handled by admin-sharing.js separately. (effort: low) (impact: low)
    • Rationale: Eliminates two JS TypeErrors on every admin settings page load, reduces unnecessary script load, and keeps the admin console clean for debugging.

What works well (praises)

  • [Jetpack REST API security] All Jetpack REST API endpoints (/jetpack/v4/module/all, /jetpack/v4/module/{slug}, /jetpack/v4/settings, /jetpack/v4/plugins) return 401 for unauthenticated requests with a proper error structure.
    • Why: Consistent, correct authentication enforcement across all tested endpoints. No publicly-accessible module management endpoints were found.
  • [Jetpack admin capability gating] Subscriber-level users are correctly blocked from all Jetpack admin pages (admin.php?page=jetpack and admin.php?page=jetpack_modules return 'Sorry, you are not allowed to access this page.').
    • Why: Capability checks are properly enforced — low-privilege users cannot access plugin configuration UI.
  • [Jetpack dashboard offline mode UX] The offline mode banner is prominent and clearly explains why features are unavailable ('Currently in Offline Mode because: Your site URL is a known local development environment URL'). Each unavailable feature widget shows 'Unavailable in Offline Mode' rather than failing silently.
    • Why: Clear, consistent communication of feature availability. Users understand exactly what is and is not available without needing to guess or search documentation.
  • [Contact Form block (F4/F32)] The Jetpack Contact Form block renders correctly on the frontend with proper form fields (Name, Email, Message), required field indication, and a submit button
    • Why: The block-to-frontend rendering pipeline works correctly for the most important form block. The form HTML is clean and accessible with proper labeling.
  • [Shortcodes — YouTube/Vimeo (F8)] The [youtube] shortcode correctly renders an iframe with a proper YouTube Video Player accessible label. The [vimeo] shortcode also renders as an iframe.
    • Why: oEmbed handling for major video platforms works correctly in shortcode form.
  • [Contact Forms — XSS sanitization] HTML content in form submissions is sanitized at the server input processing level before storage, preventing stored XSS. Script-only payloads are rejected entirely at validation. The sanitization is applied both to storage (DB confirmed plain text) and display (inbox detail panel shows stripped content).
    • Why: Defense-in-depth approach: sanitize on input AND verify on output. The fact that pure script payloads fail validation entirely (rather than being stored safely-escaped) is an additional layer of protection.
  • [Contact Forms — form validation UX] Invalid form submissions produce multiple layers of clear feedback: (1) inline error message under each invalid field, (2) summary message 'Please fill out the form correctly.', (3) error list with jump-to-field links. Both empty-required-field and invalid-email-format cases are handled distinctly with specific error messages.
    • Why: This is notably well-implemented validation UX — many contact form plugins only show inline errors or only show a summary, not both. The jump-to-field links in the error list are particularly useful for accessibility.
  • [Contact Forms — submission confirmation] After successful form submission, the page immediately shows a confirmation ('Thank you for your response.') with the submitted field values echoed back, allowing the submitter to verify what was sent. The confirmation is inline (no page navigation required).
    • Why: Immediate confirmation with data recap reduces user uncertainty about whether the form was submitted successfully and helps catch any typos in what was sent.
  • [Module state synchronization] Module state changes made via the React SPA settings immediately reflect on the PHP Modules page, and vice versa. Toggling carousel off in React settings removed it from jetpack_active_modules within milliseconds, and the PHP Modules page correctly showed 'Activate' on the next page load. The reverse direction (activating from PHP page, checking React SPA) also worked cleanly.
    • Why: Bidirectional state sync between two parallel admin UIs is technically non-trivial. Getting this right prevents users from seeing contradictory activation states across surfaces.
  • [Module lifecycle — capability and nonce verification] Both capability check (current_user_can('jetpack_deactivate_modules') / current_user_can('jetpack_activate_modules')) AND nonce verification (check_admin_referer()) are present on every module activation and deactivation action in class.jetpack.php
    • Why: This is the correct defense-in-depth pattern: capability check prevents unauthorized users from triggering the action even with a valid nonce, while the nonce prevents CSRF from tricked admin users. Both checks are consistently applied to activate, deactivate, disconnect, reconnect, and unlink actions.
  • [Module lifecycle — partial deactivation consistency] Deactivating one module from a multi-module active set leaves the remaining modules in a correct, consistent state with proper Activate/Deactivate button rendering
    • Why: The update_active() method in class-modules.php correctly computes the diff between old and new active modules, firing appropriate hooks for newly activated and deactivated modules without corrupting the state of unaffected modules.
  • [AJAX handler access control — Carousel (F7)] The carousel comment handler correctly implements nonce verification (wp_verify_nonce with 'carousel_nonce') before processing any comment data, and validates required fields (blog_id, post_id, comment content) before calling wp_new_comment()
    • Why: Nonce verification prevents CSRF attacks on the comment endpoint. Field validation provides clear error messages rather than silently failing. The handler correctly uses wp_new_comment() which applies WordPress core's comment sanitization pipeline.
  • [AJAX handler access control — Sharing (F6)] All four sharing AJAX handlers correctly verify the WordPress nonce before executing any data operations, with action-specific nonce strings (sharing-options, sharing-new_service, sharing-options_)
    • Why: Action-specific nonces are best practice — they prevent one nonce from being replayed across different endpoints. The nonce implementation itself is correct; only the missing capability check is the defect.
  • [WordAds GDPR Consent Management] The source code explicitly documents the security tradeoffs with phpcs:ignore comments and TODO notes, making it easy to identify intentional design decisions vs accidental omissions.
    • Why: The comment 'Client side CMP needs to be able to read this value' explains why HttpOnly=false is intentional, and the TODO 'Is there better sanitizing we can do here?' shows the developer was aware of the input validation gap. This transparency in code comments aids security review.
  • [Sharing global options sanitization] The set_global_options() function validates each field individually using whitelists (button_style, open_links) and wp_kses with empty allowed tags (sharing_label) rather than storing raw input from $_POST.
    • Why: Despite the code pattern appearing risky (passing raw $_POST to the function), the internal sanitization prevents stored XSS. The individual field validation approach is more robust than relying on a single upstream sanitize call.
  • [Custom sharing service output escaping] The Share_Custom class correctly applies esc_html() when rendering the service name in both admin (output_service at sharing.php:298) and frontend (get_display at sharing-sources.php:2340) contexts.
    • Why: Defense in depth: sanitization on input AND escaping on output means even if the storage sanitization were bypassed, the output would still be safe.
  • [Sharing buttons — configuration and frontend rendering] The end-to-end sharing button flow works reliably: activate module, configure services in admin, and buttons appear immediately on published posts without cache clearing or reindexing.
    • Why: Many plugin features require multiple configuration steps and don't provide reliable immediate-effect behavior. This feature provides a smooth admin-configure-to-frontend-render path.
  • [Sharing buttons — settings persistence] Settings save provides clear 'Settings have been saved' feedback notice and button style selection (Icon only, Icon+text, Text only, Official buttons) persists correctly across page reloads.
    • Why: Save feedback and persistence are basic but frequently broken. Both work correctly here.
  • [Sharing buttons — button style live preview] The admin settings page shows a live preview that updates in real-time when the button style dropdown is changed, giving admins instant visual feedback before saving.
    • Why: This is a thoughtful UX feature that reduces the admin's need to save-and-check-frontend repeatedly when choosing a style.

Coverage gaps

Session Status Turns Flows Notes
breadth-tour-admin complete 18/18 6/6 All 9 BTA hypotheses probed. Subscriber capability gate verified. F16 (Memberships) confirmed offline-gated — not further explored per charter scope. Markdown rendering via direct filter call showed no conversion (WP-CLI context may differ from frontend filter chain). BTA5 (Subscriptions) partially tested — Newsletter module is offline-gated, toggle not available; the Discussion tab shows no Subscriptions-specific toggle.
breadth-tour-frontend complete 18/18 9/10 BTF2 carousel probe incomplete due to test post having no gallery images; carousel JS not loaded so lightbox behavior could not be triggered. BTF8 VideoPress probed indirectly — module not activated (not in charter's active_modules list). BTF10 cookie consent block does not exist in this version of Jetpack. Scale-sensitive c2 fallback: sitemaps module cron not registered (no WP.com connection), sitemap returns 404.
contact-forms-depth complete 8/8 5/6 CF1 (happy path end-to-end) fully verified. CF2 (validation) verified for both empty required fields and invalid email format. CF3 (inbox) verified with submission data, columns (From, Date, Source, IP, Actions), and detail panel. CF4 (XSS sanitization) verified: content sanitized before storage — DB shows stripped content. The first XSS-only submission was rejected at server validation (empty after sanitization = 'required' error), preventing storage entirely. The 'Forms' tab in the inbox shows '0 forms' while 'Responses' tab shows submissions — the distinction between 'Forms' (created form templates) and 'Responses' (submissions) is working as intended.
module-interdependency-seams complete 10/10 5/5 All four charter hypotheses (MI1-MI4) probed. Recon S2 version string discrepancy not reproducible. MI3 refined: 'Create address' button is actually disabled (not active as recon S6 stated); the UX surface inconsistency is real but milder than recon implied. MI1 confirmed as passing — state sync works bidirectionally between React SPA and PHP Modules page.
module-lifecycle-destructive complete 12/12 7/7 All 7 b1-b7 anchors probed. Cron cleanup hypothesis confirmed as a bug. H17 uninstall blast radius analyzed via source inspection as directed by charter out-of-scope constraint (no full uninstall executed). Default blast radius probed: uninstall.php does NOT clear jp_sitemap_cron_hook or delete jp_sitemap/jp_sitemap_master/jp_img_sitemap/jp_vid_sitemap CPT posts — these are orphaned on uninstall. Feedback CPT posts (from contact-form module) also not deleted by uninstall.php.
security-ajax-capability complete 8/8 6/6 All three hypotheses (H1, H7, H8) were probed within the 8-turn budget. H1 confirmed as major privilege escalation. H7 confirmed as functional but note WP core rate-limiting mitigates spam severity somewhat. H8 confirmed that filter_var(FILTER_DEFAULT) is a no-op but downstream wp_new_comment sanitization prevents XSS. Sibling propagation of H1 pattern confirmed for sharing_new_service handler.
security-cookie-consent complete 8/8 4/4 All 4 hypotheses probed. H2 and H4 required workaround (mu-plugin) due to WordAds module's WP.com connection requirement in offline environment. H5 confirmed via browser evaluate — JWT cookie is JavaScript-readable. H6 confirmed via curl Set-Cookie response header (no HttpOnly flag) and source analysis. Full XSS chain for H4 not confirmed — source grep shows euconsent-v2 cookie value is never echoed unescaped in page output, so the stored XSS escalation is blocked at the read side. Downstream cookie use requires PHP decode of URL-encoded value which further mitigates browser-side XSS execution.
security-input-output complete 8/8 4/4 All four hypotheses (H3, H9, H15, H16) were probed empirically. H3 is confirmed as a real defect — the endpoint returns the configured Mapbox API key to any unauthenticated visitor. H9 is refuted — JWT validation gates the redirect and the endpoint correctly rejects invalid tokens. H15 is refuted — sanitize_text_field strips HTML tags from the service name before storage, and output_service() uses esc_html(). H16 is refuted — wp_kses($data['sharing_label'], array()) strips HTML tags before storage, and the frontend uses esc_html() on output.
sharing-buttons-depth complete 8/8 4/4 All three charter hypotheses probed. SH1 (buttons render on frontend) confirmed pass. SH2 (settings save with feedback and persist) confirmed pass. SH3 (button style changes reflect on frontend) confirmed pass. One minor bug found: sharing.js (frontend script) is also loaded on the admin settings page, causing two JS TypeError exceptions on each page load of options-general.php?page=sharing. The errors do not break functionality — the live preview and save operations work correctly — but they indicate unnecessary script loading and potential brittleness. Guest persona verified implicitly: the sharing buttons use no auth-gated markup and the frontend URL is public. React SPA settings page was deprioritized due to budget exhaustion.

Environment warnings

These are signals observed during the run that point at test-environment quirks (Studio + SQLite shim, WP-CLI Phar, WC stack interactions), NOT plugin defects. Apply extra scrutiny to findings in affected areas — some Problems may be false positives caused by the environment, and some real bugs may be masked.

Session Warning
breadth-tour-admin Disk space critically low (~118MB free) during session — screenshot capture was limited to the most important findings only. Network log capture was unavailable (playwright-cli version does not expose 'network' command).
breadth-tour-admin Studio site running in offline mode (local development URL) — many Jetpack features that require WP.com connection are disabled. Findings related to my-jetpack 403 and Account Protection 400 errors may behave differently in a connected production environment.
breadth-tour-frontend Jetpack sitemaps module did not register cron events — may be due to missing WP.com connection in Studio offline environment; the 404 at /sitemap.xml may partially reflect this env constraint rather than a plugin bug alone.
breadth-tour-frontend WP-CLI Phar eval commands failed with 'Parse error: unexpected token backslash' when using PHP lambda functions with anonymous function syntax — Studio + WP-CLI Phar + PHP 8.4 interaction; workaround was to simplify eval expressions.
breadth-tour-frontend Block theme (Twenty Twenty-Five or similar) in use is not widget-aware; Appearance > Widgets page blocked widget UI testing for BTF4 — theme limitation, not a Jetpack defect.
breadth-tour-frontend Disk space near capacity (144MB free on /dev/disk3s5) during session — may have affected playwright-cli snapshot performance; some commands retried.
module-interdependency-seams Site is in Jetpack Offline Mode because URL is localhost:8887 — this affects availability of WP.com-connected features and is expected behavior for the test environment. Findings about offline-mode-gated features (Brute Force Protection, Post by Email, my-jetpack access) are reproducible in any offline/local environment, not just this test stack.
module-interdependency-seams SQLite database integration in use (Studio local dev environment) — no MySQL-specific behavior observed that would affect these surface-consistency findings.
security-cookie-consent WordAds module requires WP.com connection to load in production; a mu-plugin was used to force-register the GDPR AJAX handler for testing. The code path executed is identical to production, but the activation mechanism differs. Findings P1 and P2 are about the handler code itself, not the activation path.
security-cookie-consent euconsent-v2 cookie domain set to '.localhost:8889' (port included in domain) which is invalid for browser cookie setting, causing the cookie not to appear in document.cookie in the browser test. This is a localhost-specific behavior; on production domains (e.g., '.example.com'), the cookie sets correctly without HttpOnly. H6 confirmed via curl Set-Cookie response header and source analysis.
security-input-output WP-CLI db query command failed with 'Undefined constant DB_HOST' — SQLite integration does not support the db command; eval-based queries used as workaround. Not a plugin defect.
security-input-output Mapbox key configured via WP-CLI for H3 testing used a synthetic test key (pk.testkey12345678) to demonstrate the exposure without needing a real Mapbox account.
sharing-buttons-depth sharing.js console errors (TypeError on document.body.addEventListener and MoreButton.setAttribute) confirmed both in browser and via source analysis — these are plugin code issues, not environment artifacts.

Invalid / failed session reports

recon

  • No report.json produced

Token usage & cost

Computed from Claude Code transcripts at ~/.claude/projects/<proj-hash>/. Rates from config/pricing.json. Window: 2026-05-05T20:17:23Z2026-05-05T21:19:27Z (with ±10min buffer for dispatch drift).

Estimated total cost for this run: $58.11

Category Cost % of total
Fresh input $0.16 0.3%
Output $4.40 7.6%
Cache-create (5m) $14.57 25.1%
Cache-create (1h) $1.32 2.3%
Cache-read $37.66 64.8%

Manager (main conversation)

Total: $3.75

Model Messages Input Output Cache-5m Cache-1h Cache-read Cost
claude-sonnet-4-6 94 164 22,468 0 219,304 6,989,440 $3.75

Subagents (18 invocations)

Total: $54.36

Model Messages Input Output Cache-5m Cache-1h Cache-read Cost
claude-sonnet-4-6 1454 53,012 203,538 2,292,528 0 105,161,867 $43.36
claude-opus-4-6 117 133 40,506 955,992 0 8,032,850 $11.00
Per-subagent breakdown (18 sessions)
Agent ID Type Models Cost
a17015e1c3a3b2a22 tester claude-sonnet-4-6 $4.69
a1b83e2b41e2bc146 planner claude-opus-4-6 $3.40
a25523cfbb5f21f5b tester claude-sonnet-4-6 $2.04
a348ac1a105da450c tester claude-sonnet-4-6 $0.76
a34aba269962c4b58 tester claude-sonnet-4-6 $3.40
a48e3e80c1550385f general-purpose claude-sonnet-4-6 $1.41
a4d61ed6db23de1ab tester claude-sonnet-4-6 $7.81
a62668bbac7b5b3c7 planner claude-opus-4-6 $7.61
a63725d67a228dd97 tester claude-sonnet-4-6 $0.43
a68030913383faa38 tester claude-sonnet-4-6 $3.57
a8292240800b05cdf tester claude-sonnet-4-6 $0.41
aab87b72edcaa6b8e tester claude-sonnet-4-6 $0.48
ab271ed5874e324b2 tester claude-sonnet-4-6 $0.43
abfc56c5e1d88b46a tester claude-sonnet-4-6 $3.60
abfdb17c34db8b448 tester claude-sonnet-4-6 $2.55
addf8bcf9cc9448ed tester claude-sonnet-4-6 $2.18
aefd3c866a6e6871b tester claude-sonnet-4-6 $4.61
af2d0c2e8ca0278f7 tester claude-sonnet-4-6 $4.99

Recommended next steps

  1. Triage WordAds GDPR Consent Management first — highest risk score (7)
  2. Follow up on 9 session(s) with incomplete coverage
  3. Investigate 1 session(s) that failed to produce valid reports
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment