From 5e3aaa3b0de6cd38c0722f44e5f591e50951be64 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Mar 2026 11:37:10 +0100 Subject: [PATCH 1/4] Cap remote recipient fetches per incoming activity Limit the number of outbound HTTP requests triggered by remote recipient URLs in to/cc/bcc fields to prevent abuse. Defaults to 5, filterable via `activitypub_max_remote_recipient_fetches`. --- includes/rest/class-inbox-controller.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 2d9f54fad1..f8ef9973df 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -372,7 +372,16 @@ public function get_item_schema() { * @return array An array of user IDs who are the recipients of the activity. */ private function get_local_recipients( $activity ) { - $user_ids = array(); + $user_ids = array(); + $remote_fetches = 0; + + /** + * Filters the maximum number of remote recipient URLs that can be + * fetched per incoming activity. + * + * @param int $max_remote_fetches Maximum number of remote fetches. Default 5. + */ + $max_remote_fetches = (int) \apply_filters( 'activitypub_max_remote_recipient_fetches', 5 ); if ( is_activity_public( $activity ) ) { $user_ids = Following::get_follower_ids( $activity['actor'] ); @@ -387,6 +396,12 @@ private function get_local_recipients( $activity ) { } if ( ! is_same_domain( $recipient ) ) { + // Cap remote fetches to prevent abuse via large to/cc/bcc lists. + if ( $remote_fetches >= $max_remote_fetches ) { + continue; + } + + ++$remote_fetches; $collection = Http::get_remote_object( $recipient ); // If it is a remote actor we can skip it. From e82bbadd1c2cda3108525c037a8d36df5c808bab Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Mar 2026 11:38:34 +0100 Subject: [PATCH 2/4] Add changelog entry for remote recipient fetch cap --- .github/changelog/fix-cap-remote-recipient-fetches | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/fix-cap-remote-recipient-fetches diff --git a/.github/changelog/fix-cap-remote-recipient-fetches b/.github/changelog/fix-cap-remote-recipient-fetches new file mode 100644 index 0000000000..5ebfbcff80 --- /dev/null +++ b/.github/changelog/fix-cap-remote-recipient-fetches @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Limited the number of remote recipient lookups per incoming activity. From f153294aa4503367340c4df5472e4ea0a913b6b2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Mar 2026 12:12:48 +0100 Subject: [PATCH 3/4] Update comment wording for recipient fetch cap --- includes/rest/class-inbox-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index f8ef9973df..87f6bfadf5 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -396,7 +396,7 @@ private function get_local_recipients( $activity ) { } if ( ! is_same_domain( $recipient ) ) { - // Cap remote fetches to prevent abuse via large to/cc/bcc lists. + // Cap remote fetches to prevent abuse via large audience/recipient fields. if ( $remote_fetches >= $max_remote_fetches ) { continue; } From 87c10db7566f2d534723d9601df4a5dbb9783bbf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 26 Mar 2026 09:40:34 +0100 Subject: [PATCH 4/4] Detect actor's followers collection from cached metadata Instead of always fetching remote recipient URLs to check if they are collections, compare them against the actor's known followers URL from the cached Remote_Actors post. This avoids consuming a remote fetch slot for the most common collection URL and ensures followers are resolved even when the fetch cap is reached. --- includes/rest/class-inbox-controller.php | 27 ++ .../rest/class-test-inbox-controller.php | 239 ++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 87f6bfadf5..7ecbda80f6 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -11,6 +11,7 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Following; use Activitypub\Collection\Inbox; +use Activitypub\Collection\Remote_Actors; use Activitypub\Http; use Activitypub\Moderation; @@ -383,6 +384,25 @@ private function get_local_recipients( $activity ) { */ $max_remote_fetches = (int) \apply_filters( 'activitypub_max_remote_recipient_fetches', 5 ); + /* + * Look up the actor's cached profile to identify their followers collection URL + * without needing a remote fetch. The actor is typically already cached from + * signature verification. + */ + $actor_followers_url = null; + + if ( ! empty( $activity['actor'] ) ) { + $actor_post = Remote_Actors::get_by_uri( $activity['actor'] ); + + if ( ! \is_wp_error( $actor_post ) ) { + $actor_data = \json_decode( $actor_post->post_content, true ); + + if ( ! empty( $actor_data['followers'] ) ) { + $actor_followers_url = $actor_data['followers']; + } + } + } + if ( is_activity_public( $activity ) ) { $user_ids = Following::get_follower_ids( $activity['actor'] ); } @@ -396,6 +416,13 @@ private function get_local_recipients( $activity ) { } if ( ! is_same_domain( $recipient ) ) { + // Detect the actor's followers collection from cached metadata (no fetch needed). + if ( $actor_followers_url && $recipient === $actor_followers_url ) { + $_user_ids = Following::get_follower_ids( $activity['actor'] ); + $user_ids = array_merge( $user_ids, $_user_ids ); + continue; + } + // Cap remote fetches to prevent abuse via large audience/recipient fields. if ( $remote_fetches >= $max_remote_fetches ) { continue; diff --git a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php index 3ffbfcf937..842f2cd802 100644 --- a/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-inbox-controller.php @@ -1372,4 +1372,243 @@ public function test_follow_request_without_audience_via_rest() { \remove_action( 'activitypub_inbox', array( $inbox_action, 'action' ) ); \delete_option( 'activitypub_actor_mode' ); } + + /** + * Test get_local_recipients detects actor's followers collection URL without fetching it. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_detects_followers_url_without_fetch() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); + + $remote_actor_url = 'https://example.com/actor/followers-detect'; + $remote_followers_url = 'https://example.com/actor/followers-detect/followers'; + + // Mock the remote actor fetch so the cached metadata includes a followers field. + $remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url, $remote_followers_url ) { + if ( $url === $remote_actor_url ) { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $remote_actor_url, + 'type' => 'Person', + 'preferredUsername' => 'testfollowersdetect', + 'name' => 'Test Actor', + 'inbox' => $remote_actor_url . '/inbox', + 'followers' => $remote_followers_url, + ); + } + return $pre; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 ); + + // Create the cached remote actor. + $remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url ); + + // Make test user follow the remote actor. + \add_post_meta( $remote_actor->ID, '_activitypub_followed_by', self::$user_id ); + + // Track which URLs are fetched via Http::get_remote_object. + $fetched_urls = array(); + $track_fetches = function ( $pre, $url ) use ( &$fetched_urls ) { + $fetched_urls[] = $url; + return new \WP_Error( 'test', 'Simulated error' ); + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5, 2 ); + + // Activity where the followers URL is in cc. + $activity = array( + 'type' => 'Create', + 'actor' => $remote_actor_url, + 'to' => array( 'https://example.com/some-other-actor' ), + 'cc' => array( $remote_followers_url ), + ); + + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $result = $method->invoke( $this->inbox_controller, $activity ); + + // The followers URL should NOT have been fetched via HTTP. + $this->assertNotContains( + $remote_followers_url, + $fetched_urls, + 'Should NOT fetch the actor followers URL via HTTP when it matches cached metadata' + ); + + // But the followers should still be resolved. + $this->assertContains( self::$user_id, $result, 'Should resolve followers without HTTP fetch' ); + + // Clean up. + \delete_option( 'activitypub_actor_mode' ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5 ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter ); + } + + /** + * Test get_local_recipients still caps remote fetches for non-followers URLs. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_remote_fetch_cap_with_followers_detection() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); + + $remote_actor_url = 'https://example.com/actor/cap-test'; + $remote_followers_url = 'https://example.com/actor/cap-test/followers'; + + $remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url, $remote_followers_url ) { + if ( $url === $remote_actor_url ) { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $remote_actor_url, + 'type' => 'Person', + 'preferredUsername' => 'captest', + 'name' => 'Cap Test', + 'inbox' => $remote_actor_url . '/inbox', + 'followers' => $remote_followers_url, + ); + } + return $pre; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 ); + + $remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url ); + + // Make test user follow the remote actor. + \add_post_meta( $remote_actor->ID, '_activitypub_followed_by', self::$user_id ); + + // Track fetches. + $fetched_urls = array(); + $track_fetches = function ( $pre, $url ) use ( &$fetched_urls ) { + $fetched_urls[] = $url; + return new \WP_Error( 'test', 'Simulated error' ); + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5, 2 ); + + // Set cap to 3 for easier testing. + $cap_filter = function () { + return 3; + }; + \add_filter( 'activitypub_max_remote_recipient_fetches', $cap_filter ); + + // 6 non-followers remote URLs + the followers URL. + $activity = array( + 'type' => 'Create', + 'actor' => $remote_actor_url, + 'to' => array( + 'https://other.example.com/user/1', + 'https://other.example.com/user/2', + 'https://other.example.com/user/3', + 'https://other.example.com/user/4', + ), + 'cc' => array( + 'https://other.example.com/user/5', + 'https://other.example.com/user/6', + $remote_followers_url, + ), + ); + + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $result = $method->invoke( $this->inbox_controller, $activity ); + + // Only 3 remote URLs should have been fetched (the cap). + $this->assertCount( 3, $fetched_urls, 'Should only fetch up to the cap limit' ); + + // The followers URL should NOT be in the fetched list (detected from cache). + $this->assertNotContains( + $remote_followers_url, + $fetched_urls, + 'Followers URL should be handled without consuming a fetch slot' + ); + + // Followers should still be resolved despite the cap. + $this->assertContains( self::$user_id, $result, 'Should resolve followers even when cap is exceeded' ); + + // Clean up. + \delete_option( 'activitypub_actor_mode' ); + \remove_filter( 'activitypub_max_remote_recipient_fetches', $cap_filter ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5 ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter ); + } + + /** + * Test get_local_recipients detects followers URL even on a different domain. + * + * Some platforms (e.g., WordPress.com) may host actors and their collections + * on different domains. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_cross_domain_followers_url() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); + + $remote_actor_url = 'https://actor-domain.example.com/actor/cross'; + // Followers on a DIFFERENT domain than the actor. + $remote_followers_url = 'https://collections-domain.example.com/actor/cross/followers'; + + $remote_object_filter = function ( $pre, $url ) use ( $remote_actor_url, $remote_followers_url ) { + if ( $url === $remote_actor_url ) { + return array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $remote_actor_url, + 'type' => 'Person', + 'preferredUsername' => 'crossdomain', + 'name' => 'Cross Domain Actor', + 'inbox' => $remote_actor_url . '/inbox', + 'followers' => $remote_followers_url, + ); + } + return $pre; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter, 10, 2 ); + + $remote_actor = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $remote_actor_url ); + + \add_post_meta( $remote_actor->ID, '_activitypub_followed_by', self::$user_id ); + + // Track fetches. + $fetched_urls = array(); + $track_fetches = function ( $pre, $url ) use ( &$fetched_urls ) { + $fetched_urls[] = $url; + return new \WP_Error( 'test', 'Simulated error' ); + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5, 2 ); + + $activity = array( + 'type' => 'Create', + 'actor' => $remote_actor_url, + 'to' => array( 'https://someone-else.example.com/user/1' ), + 'cc' => array( $remote_followers_url ), + ); + + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + if ( \PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $result = $method->invoke( $this->inbox_controller, $activity ); + + // Cross-domain followers URL should NOT have been fetched. + $this->assertNotContains( + $remote_followers_url, + $fetched_urls, + 'Should detect cross-domain followers URL from cached metadata without fetch' + ); + + // But followers should still be resolved. + $this->assertContains( self::$user_id, $result, 'Should resolve followers for cross-domain collection URL' ); + + // Clean up. + \delete_option( 'activitypub_actor_mode' ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $track_fetches, 5 ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $remote_object_filter ); + } }