Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5e3aaa3
Cap remote recipient fetches per incoming activity
pfefferle Mar 25, 2026
e82bbad
Add changelog entry for remote recipient fetch cap
pfefferle Mar 25, 2026
f153294
Update comment wording for recipient fetch cap
pfefferle Mar 25, 2026
a804ac3
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Mar 25, 2026
87c10db
Detect actor's followers collection from cached metadata
pfefferle Mar 26, 2026
ffd63fb
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Mar 26, 2026
61d34e0
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Mar 27, 2026
db8658f
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Mar 30, 2026
20b8980
Normalize actor and followers URIs in inbox recipient lookup
pfefferle Apr 26, 2026
eab7085
Skip remote fetch when recipient is already a cached actor
pfefferle Apr 27, 2026
3666792
Bump default remote-recipient fetch cap from 5 to 10
pfefferle Apr 27, 2026
b2fb126
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 27, 2026
f08f264
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 27, 2026
0535ad3
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 27, 2026
27aaf53
Bound DB cost of per-recipient cache lookup with a batched query
pfefferle Apr 27, 2026
eab27e7
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 27, 2026
6b0c6bf
Match get_actor()'s legacy postmeta fallback in cached followers lookup
pfefferle Apr 27, 2026
c7616cb
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 27, 2026
9b89c46
Merge branch 'trunk' into fix/cap-remote-recipient-fetches
pfefferle Apr 28, 2026
853e10f
Cover inline-object actor/followers and legacy postmeta fallback in t…
pfefferle Apr 28, 2026
1940d3e
Add action hook when remote recipient fetch cap is reached
pfefferle Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: security

Hardened how the inbox processes large recipient lists in incoming activities.
43 changes: 43 additions & 0 deletions includes/collection/class-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,49 @@ public static function get_by_uri( $actor_uri ) {
return $post;
}

/**
* Look up which of the given URIs already exist as cached remote actors.
*
* Single batched query (chunked at 200 placeholders to stay well within
* common DB limits). Use this instead of looping over `get_by_uri()` when
* a caller only needs to know which URIs are known — e.g. the inbox
* recipient resolver, where a flood of unknown recipients would otherwise
* trigger one DB query per recipient.
*
* @since unreleased
*
* @param string[] $uris Candidate actor URIs.
*
* @return array<string, true> Map of URIs that exist, keyed for O(1) lookup.
*/
public static function get_existing_uris( $uris ) {
if ( empty( $uris ) ) {
return array();
}

global $wpdb;
$existing = array();

foreach ( \array_chunk( \array_values( \array_unique( $uris ) ), 200 ) as $chunk ) {
$placeholders = \implode( ', ', \array_fill( 0, \count( $chunk ), '%s' ) );

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$found = $wpdb->get_col(
$wpdb->prepare(
"SELECT guid FROM $wpdb->posts WHERE post_type = %s AND guid IN ( $placeholders )",
\array_merge( array( self::POST_TYPE ), $chunk )
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

foreach ( $found as $uri ) {
$existing[ $uri ] = true;
}
}

return $existing;
}

/**
* Fetch a remote actor post by either actor URI or acct, fetching from remote if not found locally.
*
Expand Down
115 changes: 110 additions & 5 deletions 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 All @@ -19,6 +20,7 @@
use function Activitypub\is_activity_public;
use function Activitypub\is_collection;
use function Activitypub\is_same_domain;
use function Activitypub\object_to_uri;
use function Activitypub\user_can_activitypub;

/**
Expand Down Expand Up @@ -377,31 +379,101 @@ 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;
$cap_notified = false;

/**
* Filters the maximum number of remote recipient URLs that can be
* fetched per incoming activity.
*
* @since unreleased
*
* @param int $max_remote_fetches Maximum number of remote fetches. Default 10.
*/
$max_remote_fetches = (int) \apply_filters( 'activitypub_max_remote_recipient_fetches', 10 );

// AS2 allows actor and followers to be either an IRI string or an inline object; normalize to a URI.
$actor_uri = ! empty( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
$actor_followers_url = $this->get_cached_followers_url( $actor_uri );

if ( is_activity_public( $activity ) ) {
$user_ids = Following::get_follower_ids( $activity['actor'] );
$user_ids = Following::get_follower_ids( $actor_uri );
}

$recipients = extract_recipients_from_activity( $activity );

/*
* Pre-compute which recipients are already known remote actors so the
* cached-actor short-circuit becomes an O(1) array lookup rather than
* one DB query per recipient. This bounds the DB cost of a flood of
* unknown recipient URIs to one batched SELECT (chunked) regardless
* of how many were sent.
*/
$candidate_uris = array();
foreach ( $recipients as $recipient ) {
if (
! \is_string( $recipient )
|| \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true )
|| is_same_domain( $recipient )
|| $recipient === $actor_followers_url
) {
continue;
}
$candidate_uris[] = $recipient;
}
$cached_uris = $candidate_uris ? Remote_Actors::get_existing_uris( $candidate_uris ) : array();

foreach ( $recipients as $recipient ) {
// Skip public audience identifiers - they're not actual recipients to fetch.
if ( \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true ) ) {
continue;
}

if ( ! is_same_domain( $recipient ) ) {
// Known followers collection: resolve from local DB, no fetch needed.
if ( $recipient === $actor_followers_url ) {
$user_ids = array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
continue;
}

// Already cached as a remote actor: not a collection, so no local recipients to add.
if ( isset( $cached_uris[ $recipient ] ) ) {
continue;
}

// Unknown URL: cap remote fetches to prevent abuse via large audience/recipient fields.
if ( $remote_fetches >= $max_remote_fetches ) {
if ( ! $cap_notified ) {
$cap_notified = true;

/**
* Fires when an incoming activity hits the remote recipient fetch cap.
*
* Fires once per activity on the first recipient that exceeds the cap,
* not for each subsequent skipped recipient. Hook this to surface
* cap hits in your logging system of choice (Jetpack, Sentry, syslog, etc.).
*
* @since unreleased
*
* @param array $activity The incoming activity data.
* @param string $recipient The recipient URI that was skipped.
* @param int $cap The configured cap.
*/
\do_action( 'activitypub_remote_recipient_fetch_cap_reached', $activity, $recipient, $max_remote_fetches );
}
continue;
}
Comment thread
pfefferle marked this conversation as resolved.
++$remote_fetches;

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

// If it is a remote actor we can skip it.
if ( \is_wp_error( $collection ) ) {
continue;
}

if ( is_collection( $collection ) ) {
$_user_ids = Following::get_follower_ids( $activity['actor'] );
$user_ids = array_merge( $user_ids, $_user_ids );
$user_ids = array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
continue;
}
}
Expand Down Expand Up @@ -430,4 +502,37 @@ private function get_local_recipients( $activity ) {

return array_unique( array_map( 'intval', $user_ids ) );
}

/**
* Look up an actor's followers collection URL from the cached profile.
*
* Used to detect followers-addressed recipients without an outbound fetch.
*
* @param string|null $actor_uri Normalized actor URI.
*
* @return string|null The followers collection URL, or null if not cached/available.
*/
private function get_cached_followers_url( $actor_uri ) {
if ( empty( $actor_uri ) ) {
return null;
}

$actor_post = Remote_Actors::get_by_uri( $actor_uri );
if ( \is_wp_error( $actor_post ) ) {
return null;
}

// Match Remote_Actors::get_actor()'s storage fallback: legacy actor JSON lives in postmeta when post_content is empty.
$json = $actor_post->post_content;
if ( empty( $json ) ) {
$json = \get_post_meta( $actor_post->ID, '_activitypub_actor_json', true );
}

$actor_data = \json_decode( $json, true );
if ( empty( $actor_data['followers'] ) ) {
return null;
}

return object_to_uri( $actor_data['followers'] );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,57 @@ public function test_get_by_uri() {
$this->assertWPError( $empty );
}

/**
* Test get_existing_uris returns the cached subset of input URIs.
*
* @covers ::get_existing_uris
*/
public function test_get_existing_uris() {
$alice = array(
'id' => 'https://remote.example.com/actor/alice-batch',
'type' => 'Person',
'url' => 'https://remote.example.com/actor/alice-batch',
'inbox' => 'https://remote.example.com/actor/alice-batch/inbox',
'name' => 'Alice',
'preferredUsername' => 'alice-batch',
);
$bob = array(
'id' => 'https://remote.example.com/actor/bob-batch',
'type' => 'Person',
'url' => 'https://remote.example.com/actor/bob-batch',
'inbox' => 'https://remote.example.com/actor/bob-batch/inbox',
'name' => 'Bob',
'preferredUsername' => 'bob-batch',
);
$this->assertNotWPError( Remote_Actors::create( $alice ) );
$this->assertNotWPError( Remote_Actors::create( $bob ) );

$result = Remote_Actors::get_existing_uris(
array(
$alice['id'],
$bob['id'],
'https://nowhere.example/never-cached',
)
);

$this->assertSame(
array(
$alice['id'] => true,
$bob['id'] => true,
),
$result
);
}

/**
* Test get_existing_uris returns an empty array when given no input.
*
* @covers ::get_existing_uris
*/
public function test_get_existing_uris_empty_input() {
$this->assertSame( array(), Remote_Actors::get_existing_uris( array() ) );
}

/**
* Tests clear_errors.
*
Expand Down
Loading
Loading