Skip to content
Open
4 changes: 4 additions & 0 deletions .github/changelog/fix-cap-remote-recipient-fetches
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Limited the number of remote recipient lookups per incoming activity.
44 changes: 43 additions & 1 deletion includes/rest/class-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -372,7 +373,35 @@ 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 );

/*
* 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'] );
Expand All @@ -387,6 +416,19 @@ 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;
}

++$remote_fetches;
$collection = Http::get_remote_object( $recipient );

// If it is a remote actor we can skip it.
Expand Down
239 changes: 239 additions & 0 deletions tests/phpunit/tests/includes/rest/class-test-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
Loading