ActivityPub < 8.0.2 - Unauthenticated Information Epxosure
Description
The ActivityPub plugin for WordPress is vulnerable to Sensitive Information Exposure in all versions up to 8.0.2 (exclusive). This makes it possible for unauthenticated attackers to extract sensitive user or configuration data.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NTechnical Details
What Changed in the Fix
Changes introduced in v8.0.2
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-4338 (ActivityPub Information Exposure) ## 1. Vulnerability Summary The ActivityPub plugin for WordPress is vulnerable to **Unauthenticated Sensitive Information Exposure** in versions up to 8.0.2. The vulnerability exists within the plugin's custom REST API e…
Show full research plan
Exploitation Research Plan: CVE-2026-4338 (ActivityPub Information Exposure)
1. Vulnerability Summary
The ActivityPub plugin for WordPress is vulnerable to Unauthenticated Sensitive Information Exposure in versions up to 8.0.2. The vulnerability exists within the plugin's custom REST API endpoints (registered in activitypub.php). Specifically, the controllers responsible for serving ActivityPub objects (like Rest\Post_Controller and Rest\Outbox_Controller) fail to adequately verify post visibility, status, or user permissions before transforming and serving data. This allows an unauthenticated attacker to retrieve content from private posts, password-protected posts, or restricted user profiles by querying the ActivityPub REST API namespace.
2. Attack Vector Analysis
- Endpoints:
- Single Post:
/wp-json/activitypub/1.0/posts/<id> - Actor Outbox:
/wp-json/activitypub/1.0/actors/<user_id>/outbox - Actor Profile:
/wp-json/activitypub/1.0/actors/<user_id>
- Single Post:
- HTTP Method:
GET - Authentication: Unauthenticated (No cookies or credentials required).
- Payload: No specific payload is required beyond the ID of the sensitive resource.
- Preconditions: The ActivityPub plugin must be active. A post must exist that is set to
privateor has a password.
3. Code Flow
- Entry Point: An unauthenticated
GETrequest is made to a route registered inactivitypub.phpviarest_init(), such asRest\Post_Controller. - Route Handling: The
Rest\Post_Controller::get_item()method (inferred) is invoked. - Data Retrieval: The controller fetches the post using
get_post($id). - Vulnerable Sink: The controller fails to call
is_post_disabled()(fromincludes/functions-post.php) or checkcurrent_user_can('read_post', $post_id). - Transformation: The post object is passed to
Activitypub\Transformer\Post::to_object()(inincludes/transformer/class-post.php). - Output: The transformer converts the full post content (including private content) into an ActivityPub
NoteorArticleJSON object, which is then returned to the unauthenticated requester.
4. Nonce Acquisition Strategy
This vulnerability involves unauthenticated information exposure via GET requests to REST API endpoints.
- Nonce Requirement: In WordPress, REST API
GETroutes typically do not require a nonce unless they are specifically protected by apermission_callbackthat checks for one. - Bypass: Since the vulnerability is categorized as unauthenticated exposure, the
permission_callbackis either missing, returnstrue, or fails to check the user's authorization to view the specific object. - Verification: If an initial request returns a 401/403 error, the
Rest\Post_Controllermay be checked for itspermission_callbackimplementation. However, for "discovery" endpoints in ActivityPub, nonces are rarely used.
5. Exploitation Strategy
Step 1: Discover Post IDs
Identify IDs of posts on the target site. Standard WordPress behavior often leaks these via /wp-json/wp/v2/posts for public posts, but for private posts, an attacker may simply brute-force IDs (e.g., 1 to 100).
Step 2: Extract Private Post Content
Query the ActivityPub post endpoint for a known private post.
- Request:
GET /wp-json/activitypub/1.0/posts/<PRIVATE_POST_ID> HTTP/1.1 Host: TARGET_HOST Accept: application/activity+json
Step 3: Extract Password-Protected Content
Query the ActivityPub post endpoint for a post that is password-protected.
- Request:
GET /wp-json/activitypub/1.0/posts/<PASSWORD_POST_ID> HTTP/1.1 Host: TARGET_HOST Accept: application/activity+json
Step 4: Extract Outbox History
Query the author's outbox to see a timeline of activities, which may include summaries or links to content otherwise hidden.
- Request:
GET /wp-json/activitypub/1.0/actors/<USER_ID>/outbox HTTP/1.1 Host: TARGET_HOST Accept: application/activity+json
6. Test Data Setup
To verify the vulnerability, the following data must be prepared in the test environment using WP-CLI:
Create a Private Post:
wp post create --post_type=post --post_title="Sensitive Internal Memo" --post_content="This is secret content only for admins." --post_status=private --post_author=1Note the resulting ID (e.g., ID 5).
Create a Password-Protected Post:
wp post create --post_type=post --post_title="Protected Strategy" --post_content="The password is required to see this strategy." --post_status=publish --post_password="password123" --post_author=1Note the resulting ID (e.g., ID 6).
Ensure Plugin is Configured:
Enable the ActivityPub plugin if not already active.wp plugin activate activitypub
7. Expected Results
- Success: The REST API response for the private post (ID 5) returns a JSON object with
type: "Note"ortype: "Article"containing the string"This is secret content only for admins."in thecontentfield. - Success: The REST API response for the password-protected post (ID 6) returns the post content without requiring the password.
- Failure (Patched): The REST API returns a
403 Forbidden,401 Unauthorized, or a404 Not Founderror, or the JSON object contains a generic "Private" message instead of the content.
8. Verification Steps
- Direct HTTP Check: Use the
http_requesttool to perform theGETrequests described in Section 5. - Content Verification: Parse the JSON response body and search for the specific strings created in the Test Data Setup.
- Log Check: Check the WordPress error logs or access logs to confirm the requests were handled by the ActivityPub REST handlers.
9. Alternative Approaches
- Content Negotiation: If the
/wp-json/activitypub/1.0/posts/route is not directly accessible, try requesting the standard permalink of a private post with the headerAccept: application/activity+json. The plugin'sincludes/functions-request.phplogic might hijack the request and serve the JSON representation. - User Discovery: Query
/wp-json/activitypub/1.0/actors/1to see if sensitive metadata (like the user's slug or internal IDs) is exposed for users who have not opted into federation.
Summary
The ActivityPub plugin for WordPress exposes sensitive content from non-public posts (drafts, pending, scheduled, or private) via its custom REST API endpoints. Unauthenticated attackers can bypass standard WordPress visibility restrictions and password protections by accessing the ActivityPub JSON representation of restricted resources.
Vulnerable Code
// includes/functions-post.php lines 36-42 if ( $is_local_or_private || ! \post_type_supports( $post->post_type, 'activitypub' ) || 'private' === $post->post_status || ! empty( $post->post_password ) ) { $disabled = true; } --- // includes/transformer/class-post.php line 596 // Remove Content from drafts. if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { $this->content = \__( '(This post is being modified)', 'activitypub' ); return $this->content; }
Security Fix
@@ -29,10 +29,14 @@ $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); $is_local_or_private = in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true ); + // Only 'publish' is public. 'inherit' is allowed only for attachments. + $is_public_status = 'publish' === $post->post_status || + ( 'inherit' === $post->post_status && 'attachment' === $post->post_type ); + if ( $is_local_or_private || ! \post_type_supports( $post->post_type, 'activitypub' ) || - 'private' === $post->post_status || + ! $is_public_status || ! empty( $post->post_password ) ) { $disabled = true; @@ -40,14 +44,17 @@ /* * Check for posts that need special handling. - * Federated posts changed to local/private need Delete activity. + * Federated posts changed to local/private or non-public status need Delete activity. * Deleted posts restored to public need Create activity. */ $object_state = get_wp_object_state( $post ); if ( ACTIVITYPUB_OBJECT_STATE_DELETED === $object_state || - ( $is_local_or_private && ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_state ) + ( + ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_state && + ( $is_local_or_private || ! $is_public_status ) + ) ) { $disabled = false; } @@ -359,10 +359,10 @@ } /* - * Remove attachments from the Fediverse if a post was federated and then set back to draft. + * Remove attachments from the Fediverse if a post was federated and then unpublished. * Except in preview mode, where we want to show attachments. */ - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { + if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) { $this->attachment = array(); return $this->attachment; @@ -543,8 +543,8 @@ return $this->summary; } - // Remove Teaser from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { + // Remove Teaser from unpublished posts. + if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) { $this->summary = \__( '(This post is being modified)', 'activitypub' ); return $this->summary; @@ -593,8 +593,8 @@ return $this->content; } - // Remove Content from drafts. - if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { + // Remove Content from unpublished posts. + if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) { $this->content = \__( '(This post is being modified)', 'activitypub' ); return $this->content;
Exploit Outline
The exploit targets the ActivityPub REST API namespace (default: `/wp-json/activitypub/1.0/`). An unauthenticated attacker sends a GET request to either the post endpoint (`/posts/<ID>`) or the user's outbox endpoint (`/actors/<USER_ID>/outbox`). Because the plugin's internal visibility check only explicitly blocked 'private' status and 'draft' content in the transformer, posts with other non-public statuses (like 'pending' or 'future') or password-protected posts were served in full. By setting the `Accept` header to `application/activity+json`, the attacker receives the JSON representation of the sensitive post, including its full content and attachments.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.