updated plugin ActivityPub version 9.1.0

This commit is contained in:
2026-07-28 15:03:10 +00:00
committed by Gitium
parent bf428f0e45
commit ff806e1811
217 changed files with 6098 additions and 3025 deletions

View File

@ -11,6 +11,7 @@ use Activitypub\Collection\Following;
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\is_same_actor;
use function Activitypub\object_to_uri;
/**
@ -42,13 +43,22 @@ class Accept {
return;
}
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ) );
/*
* For a Follow Accept, the sender must be the actor that was followed.
* Without this, a signed Accept from one actor could confirm a Follow that
* targeted another actor by referencing that pending Follow's outbox GUID.
*/
if ( ! is_same_actor( $accept['actor'] ?? '', $accept['object']['object'] ?? '' ) ) {
return;
}
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $accept['object']['object'] ?? '' ) );
if ( \is_wp_error( $actor_post ) ) {
return;
}
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
$result = Following::accept( $actor_post, $user_id );
$success = ! \is_wp_error( $result );

View File

@ -53,13 +53,29 @@ class Announce {
self::maybe_save_announce( $announcement, $user_ids );
if ( is_string( $announcement['object'] ) ) {
$object = Http::get_remote_object( $announcement['object'] );
} else {
$object = $announcement['object'];
}
$object_url = object_to_uri( $announcement['object'] );
if ( ! $object || is_wp_error( $object ) ) {
// Force no redirects for this object's request only, so the requested host stays the authoritative origin.
$no_redirects = static function ( $args, $url ) use ( $object_url ) {
if ( $url === $object_url ) {
$args['redirection'] = 0;
}
return $args;
};
/*
* Fetch the activity from its own id rather than the inline copy the Announce
* carries: that copy is the announcer's, who is not necessarily the activity's
* author. Redirects are forbidden (above) and the cache is bypassed so the
* requested host is the authoritative origin — otherwise a redirect, or a
* response cached from an earlier redirect-following fetch, could resolve to
* attacker content while the host check below still saw the trusted host.
*/
\add_filter( 'http_request_args', $no_redirects, 10, 2 );
$object = Http::get_remote_object( $object_url, false );
\remove_filter( 'http_request_args', $no_redirects, 10 );
if ( ! $object || \is_wp_error( $object ) || ! \is_array( $object ) ) {
return;
}
@ -67,6 +83,19 @@ class Announce {
return;
}
$origin_host = \strtolower( (string) \wp_parse_url( (string) $object_url, \PHP_URL_HOST ) );
$actor_host = \strtolower( (string) \wp_parse_url( (string) object_to_uri( $object['actor'] ?? '' ), \PHP_URL_HOST ) );
/*
* Only an actor's own server may vouch for an activity attributed to it, so the
* host it was fetched from must equal its actor's host — the same key-host ==
* actor-host binding verify_key_id() enforces for signed requests, generalised
* to every relayed activity type.
*/
if ( '' === $origin_host || '' === $actor_host || $origin_host !== $actor_host ) {
return;
}
$type = \strtolower( $object['type'] );
/**
@ -102,7 +131,8 @@ class Announce {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
// Match any status, so a repost that was marked as spam or trashed still counts as seen.
$exists = Comment::object_id_to_comment( \esc_url_raw( $url ), array( 'status' => 'any' ) );
if ( $exists ) {
return;
}
@ -115,9 +145,9 @@ class Announce {
$success = false;
$result = Interactions::add_reaction( $activity );
if ( $result && ! is_wp_error( $result ) ) {
if ( $result && ! \is_wp_error( $result ) ) {
$success = true;
$result = get_comment( $result );
$result = \get_comment( $result );
}
/**

View File

@ -66,7 +66,7 @@ class Collection_Sync {
// Check for followers collection.
$collection_type = null;
if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
if ( \preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) {
$collection_type = 'followers';
}
@ -89,10 +89,10 @@ class Collection_Sync {
// Extract the user ID for cache key (collection sync is always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
$cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . md5( $actor_url );
$cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . \md5( $actor_url );
if ( false === \get_transient( $cache_key ) ) {
$frequency = self::get_frequency();
\set_transient( $cache_key, time(), $frequency );
\set_transient( $cache_key, \time(), $frequency );
} else {
return;
}
@ -127,7 +127,7 @@ class Collection_Sync {
return $args;
}
if ( ! is_array( $args['body'] ) ) {
if ( ! \is_array( $args['body'] ) ) {
$body = \json_decode( $args['body'], true );
if ( null === $body ) {
return $args;
@ -149,7 +149,7 @@ class Collection_Sync {
}
// Check if we've already sent a sync header to this authority today.
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . md5( $inbox_authority );
$transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . \md5( $inbox_authority );
if ( false !== \get_transient( $transient_key ) ) {
return $args;
}
@ -159,7 +159,7 @@ class Collection_Sync {
$args['headers']['Collection-Synchronization'] = $sync_header;
$frequency = self::get_frequency();
\set_transient( $transient_key, time(), $frequency );
\set_transient( $transient_key, \time(), $frequency );
}
return $args;
@ -196,7 +196,7 @@ class Collection_Sync {
return false;
}
if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) {
if ( \trailingslashit( $params['collectionId'] ) !== \trailingslashit( $expected_collection ) ) {
return false;
}

View File

@ -164,7 +164,7 @@ class Create {
* @param \Activitypub\Activity\Activity $activity The Activity object.
*/
public static function maybe_unbury( $outbox_id, $activity ) {
if ( ! in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
if ( ! \in_array( $activity->get_type(), array( 'Create', 'Update' ), true ) ) {
return;
}

View File

@ -156,7 +156,7 @@ class Delete {
$follower = Remote_Actors::get_by_uri( $activity['actor'] );
// Verify that Actor is deleted.
if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
if ( ! \is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) {
self::maybe_delete_interactions( $follower->ID );
self::maybe_delete_posts( $follower->ID );
$state = Remote_Actors::delete( $follower->ID );
@ -252,7 +252,7 @@ class Delete {
if ( $comments && Tombstone::exists( $id ) ) {
foreach ( $comments as $comment ) {
// WordPress will automatically delete all comment meta including _activitypub_remote_actor_id.
wp_delete_comment( $comment->comment_ID, true );
\wp_delete_comment( $comment->comment_ID, true );
}
return true;

View File

@ -0,0 +1,350 @@
<?php
/**
* Handler for FeatureRequest activities (FEP-7aa9).
*
* @package Activitypub
*/
namespace Activitypub\Handler;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\add_to_outbox;
use function Activitypub\object_to_uri;
use function Activitypub\user_can_activitypub;
/**
* Handler for FeatureRequest activities.
*
* @see https://w3id.org/fep/7aa9
*/
class Feature_Request {
/**
* Option-name prefix for the blog actor's feature stamps.
*
* The blog actor has no users-table row (its ID is 0), so its stamps cannot
* live in user meta like the stamps of regular users do. Each stamp is stored
* in its own option row, keyed `{prefix}_{id}`, so a stamp ID can be claimed
* atomically with `add_option()` without a lock.
*
* @var string
*/
const BLOG_STAMPS_OPTION = 'activitypub_blog_featured_by';
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'activitypub_inbox_feature_request', array( self::class, 'handle_feature_request' ), 10, 2 );
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
* Handle FeatureRequest activities.
*
* @param array $activity The activity object.
* @param int|int[] $user_ids The user ID(s) targeted by the inbox dispatch.
*/
public static function handle_feature_request( $activity, $user_ids ) {
$state = true;
$object_uri = object_to_uri( $activity['object'] );
$target = Actors::get_by_resource( $object_uri );
if ( \is_wp_error( $target ) ) {
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
self::queue_reject( $activity, $user_id );
return;
}
$user_id = $target->get__id();
$policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME );
switch ( $policy ) {
case ACTIVITYPUB_INTERACTION_POLICY_ANYONE:
self::queue_accept( $activity, $user_id );
break;
case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
$follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) );
if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) {
self::queue_accept( $activity, $user_id );
} else {
self::queue_reject( $activity, $user_id );
$state = false;
}
break;
case ACTIVITYPUB_INTERACTION_POLICY_ME:
default:
self::queue_reject( $activity, $user_id );
$state = false;
break;
}
/**
* Fires after an ActivityPub FeatureRequest activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $state True on accept, false otherwise.
* @param string $policy The active site policy.
*/
\do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $policy );
}
/**
* ActivityPub inbox disallowed activity.
*
* @param array $activity The activity array.
* @param int|int[]|null $user_ids The user ID(s).
* @param string $type The activity type.
*/
public static function handle_blocked_request( $activity, $user_ids, $type ) {
if ( ! \in_array( \strtolower( $type ), array( 'featurerequest', 'feature_request' ), true ) ) {
return;
}
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
self::queue_reject( $activity, $user_id );
}
/**
* Send an Accept activity in response to the FeatureRequest, issuing a stamp.
*
* Idempotent: a second call with the same instrument for the same actor reuses
* the existing stamp instead of minting a duplicate, see {@see add_stamp()}.
*
* @param array $activity_object The activity object.
* @param int $user_id The local user ID being featured (0 for the blog actor).
*/
public static function queue_accept( $activity_object, $user_id ) {
if ( ! user_can_activitypub( $user_id ) ) {
$user_id = Actors::BLOG_USER_ID;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return;
}
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
$stamp_id = self::add_stamp( $user_id, $activity_object['instrument'] );
if ( ! $stamp_id ) {
// Without a stamp there is nothing to consent with — don't send a dangling Accept.
return;
}
// Send minimal activity object back.
$activity_object = \array_intersect_key(
$activity_object,
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
'instrument' => 1,
)
);
$stamp_url = \add_query_arg(
array(
'actor' => $user_id,
'stamp' => $stamp_id,
),
\home_url( '/' )
);
$activity = new Activity();
$activity->set_type( 'Accept' );
$activity->set_actor( $actor->get_id() );
$activity->set_object( $activity_object );
$activity->set_result( $stamp_url );
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
/**
* Create (or reuse) a feature stamp for an actor.
*
* Stamps for users live in user meta, with the umeta_id doubling as the
* stamp ID. The blog actor has no users-table row, so each of its stamps is
* stored in its own option row instead.
*
* Idempotent: an existing stamp for the same instrument is reused.
*
* @since 9.0.1
*
* @param int $user_id The local actor ID (0 for the blog actor).
* @param string $instrument The instrument URI being stamped.
* @return int|false The stamp ID, or false on failure.
*/
public static function add_stamp( $user_id, $instrument ) {
if ( Actors::BLOG_USER_ID === $user_id ) {
return self::add_blog_stamp( $instrument );
}
$existing = \get_user_meta( $user_id, '_activitypub_featured_by', false );
if ( \in_array( $instrument, (array) $existing, true ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1",
$user_id,
'_activitypub_featured_by',
$instrument
)
);
}
return \add_user_meta( $user_id, '_activitypub_featured_by', $instrument );
}
/**
* Create (or reuse) a feature stamp for the blog actor.
*
* Each blog stamp lives in its own option row (`{prefix}_{id}`), so a slot is
* claimed atomically with `add_option()` — which only inserts when the row is
* absent (see Scheduler\Statistics::send_annual_email()). Concurrent inbox
* deliveries therefore can't mint the same ID or clobber each other's writes,
* and no lock is needed. Stamps are never deleted, so the slots stay gap-free
* and the walk stops at the first empty slot.
*
* @param string $instrument The instrument URI being stamped.
* @return int|false The stamp ID, or false on failure.
*/
private static function add_blog_stamp( $instrument ) {
$stamp_id = 1;
/*
* Bounded far above any realistic blog stamp count, to guard against a
* pathological object-cache state where a just-claimed slot never becomes
* visible and the re-read would otherwise spin forever.
*/
for ( $attempt = 0; $attempt < 10000; $attempt++ ) {
$key = self::BLOG_STAMPS_OPTION . '_' . $stamp_id;
$current = \get_option( $key );
// Idempotent: an existing stamp for the same instrument is reused.
if ( $current === $instrument ) {
return $stamp_id;
}
if ( false === $current ) {
if ( \add_option( $key, $instrument, '', false ) ) {
return $stamp_id;
}
// A concurrent accept claimed this slot first; re-read it (no
// increment) to see whether it took our instrument or another.
continue;
}
// Slot holds a different instrument; try the next one.
++$stamp_id;
}
return false;
}
/**
* Resolve a stamp ID for an actor to the stamped instrument URI.
*
* @since 9.0.1
*
* @param int $user_id The local actor ID (0 for the blog actor).
* @param int $stamp_id The stamp ID.
* @return string|null The instrument URI, or null if the stamp does not exist for this actor.
*/
public static function get_stamp( $user_id, $stamp_id ) {
if ( Actors::BLOG_USER_ID === $user_id ) {
$instrument = \get_option( self::BLOG_STAMPS_OPTION . '_' . (int) $stamp_id );
return false === $instrument ? null : $instrument;
}
$meta = \get_metadata_by_mid( 'user', $stamp_id );
if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $user_id ) {
return null;
}
return $meta->meta_value;
}
/**
* Send a Reject activity in response to the FeatureRequest.
*
* @param array $activity_object The activity object.
* @param int $user_id The user ID.
*/
public static function queue_reject( $activity_object, $user_id ) {
if ( ! user_can_activitypub( $user_id ) ) {
$user_id = Actors::BLOG_USER_ID;
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
return;
}
if ( isset( $activity_object['instrument'] ) ) {
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
}
// Only send minimal data.
$activity_object = \array_intersect_key(
$activity_object,
array(
'id' => 1,
'type' => 1,
'actor' => 1,
'object' => 1,
'instrument' => 1,
)
);
$activity = new Activity();
$activity->set_type( 'Reject' );
$activity->set_actor( $actor->get_id() );
$activity->set_object( $activity_object );
$activity->add_to( object_to_uri( $activity_object['actor'] ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
}
/**
* Validate the object on incoming FeatureRequest activities.
*
* @param bool $valid The current validation state.
* @param string $param The object parameter name.
* @param \WP_REST_Request $request The request object.
*
* @return bool
*/
public static function validate_object( $valid, $param, $request ) {
$activity = $request->get_json_params();
if ( empty( $activity['type'] ) ) {
return false;
}
if ( 'FeatureRequest' !== $activity['type'] ) {
return $valid;
}
if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -8,11 +8,14 @@
namespace Activitypub\Handler;
use Activitypub\Activity\Activity;
use Activitypub\Application;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
use function Activitypub\add_to_outbox;
use function Activitypub\object_to_uri;
/**
* Handle Follow requests.
@ -24,6 +27,9 @@ class Follow {
public static function init() {
\add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
\add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 );
// The Application actor cannot be followed; explicitly reject such Follows so they don't sit "pending" on the remote instance forever.
\add_action( 'activitypub_inbox_shared_follow', array( self::class, 'reject_application_follow' ), 10, 2 );
}
/**
@ -36,11 +42,6 @@ class Follow {
// Extract the user ID (follow requests are always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
if ( Actors::APPLICATION_USER_ID === $user_id ) {
self::queue_reject( $activity, $user_id );
return;
}
// Check if the actor already follows the user.
$already_following = false;
$remote_actor = Remote_Actors::get_by_uri( $activity['actor'] );
@ -103,7 +104,7 @@ class Follow {
$actor = $activity_object['actor'];
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object = \array_intersect_key(
$activity_object,
array(
'id' => 1,
@ -123,14 +124,43 @@ class Follow {
}
/**
* Send Reject response.
* Reject Follow requests aimed at the Application actor.
*
* @param array $activity The Activity array.
* @param int $user_id The ID of the WordPress User.
* The Application advertises `manuallyApprovesFollowers` but is not followable,
* so an explicit Reject is the only way a remote follow request gets resolved.
* The Reject is sent directly instead of through the Outbox, which only
* dispatches for real actors, and is signed with the Application key.
*
* @since 9.1.0
*
* @param array $activity The Follow activity data.
* @param int[] $user_ids The local recipient IDs the inbox resolved.
*/
public static function queue_reject( $activity, $user_id ) {
public static function reject_application_follow( $activity, $user_ids ) {
// A resolved recipient means the Follow targets a real actor, not the Application.
if ( ! empty( $user_ids ) ) {
return;
}
if ( empty( $activity['object'] ) || ! Application::is_application_resource( object_to_uri( $activity['object'] ) ) ) {
return;
}
$actor = object_to_uri( $activity['actor'] );
$remote_actor = Remote_Actors::fetch_by_uri( $actor );
if ( \is_wp_error( $remote_actor ) ) {
return;
}
$inbox = \get_post_meta( $remote_actor->ID, '_activitypub_inbox', true );
if ( ! $inbox ) {
return;
}
// Only send minimal data.
$origin_activity = array_intersect_key(
$origin_activity = \array_intersect_key(
$activity,
array(
'id' => 1,
@ -140,12 +170,13 @@ class Follow {
)
);
$activity = new Activity();
$activity->set_type( 'Reject' );
$activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
$activity->set_object( $origin_activity );
$activity->set_to( array( $origin_activity['actor'] ) );
$reject = new Activity();
$reject->set_type( 'Reject' );
$reject->set_id( Application::get_id() . '#reject-' . \md5( \wp_json_encode( $origin_activity ) ) );
$reject->set_actor( Application::get_id() );
$reject->set_object( $origin_activity );
$reject->set_to( array( $actor ) );
add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
Http::post( $inbox, $reject->to_json(), null );
}
}

View File

@ -41,7 +41,13 @@ class Like {
return;
}
$exists = Comment::object_id_to_comment( esc_url_raw( $url ) );
/*
* Dedupe on the Like activity ID (its source_id), not the liked object, so repeat
* deliveries stay idempotent even when a mangled author name defeats WordPress's own
* duplicate check. Match any status, so a like that was marked as spam or trashed
* still counts as seen. See https://github.com/Automattic/wordpress-activitypub/issues/3215.
*/
$exists = Comment::object_id_to_comment( \esc_url_raw( object_to_uri( $like ) ), array( 'status' => 'any' ) );
if ( $exists ) {
return;
}
@ -49,9 +55,9 @@ class Like {
$success = false;
$result = Interactions::add_reaction( $like );
if ( $result && ! is_wp_error( $result ) ) {
if ( $result && ! \is_wp_error( $result ) ) {
$success = true;
$result = get_comment( $result );
$result = \get_comment( $result );
}
/**

View File

@ -62,15 +62,17 @@ class Move {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->posts,
array( 'guid' => sanitize_url( $target_uri ) ),
array( 'ID' => sanitize_key( $origin_object->ID ) )
array( 'guid' => \sanitize_url( $target_uri ) ),
array( 'ID' => \sanitize_key( $origin_object->ID ) )
);
// Clear the cache.
\wp_cache_delete( $origin_object->ID, 'posts' );
$success = true;
$result = Remote_Actors::upsert( $target_json );
// get_remote_object() already self-confirmed the target, so it is safe to cache.
$result = Remote_Actors::upsert( $target_json );
}
// If both the target and origin are followed, merge them.
@ -177,7 +179,7 @@ class Move {
}
// Collect all possible origin identifiers (id, url, webfinger).
$origin_ids = array_filter(
$origin_ids = \array_filter(
array(
$origin_object['id'] ?? null,
$origin_object['url'] ?? null,
@ -186,7 +188,7 @@ class Move {
);
// Check if any origin identifier is in the alsoKnownAs property of the target.
if ( ! array_intersect( $origin_ids, $also_known_as ) ) {
if ( ! \array_intersect( $origin_ids, $also_known_as ) ) {
return false;
}

View File

@ -99,7 +99,7 @@ class Quote_Request {
* @param string $type The type of the activity.
*/
public static function handle_blocked_request( $activity, $user_ids, $type ) {
if ( ! in_array( strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
if ( ! \in_array( \strtolower( $type ), array( 'quoterequest', 'quote_request' ), true ) ) {
return;
}
@ -218,7 +218,7 @@ class Quote_Request {
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
$post_meta = \get_post_meta( $post_id, '_activitypub_quoted_by', false );
if ( in_array( $activity_object['instrument'], $post_meta, true ) ) {
if ( \in_array( $activity_object['instrument'], $post_meta, true ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
@ -235,7 +235,7 @@ class Quote_Request {
}
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object = \array_intersect_key(
$activity_object,
array(
'id' => 1,
@ -287,7 +287,7 @@ class Quote_Request {
$activity_object['instrument'] = object_to_uri( $activity_object['instrument'] );
// Only send minimal data.
$activity_object = array_intersect_key(
$activity_object = \array_intersect_key(
$activity_object,
array(
'id' => 1,

View File

@ -56,14 +56,24 @@ class Reject {
* @param int|int[] $user_ids The user ID(s).
*/
private static function reject_follow( $reject, $user_ids ) {
$actor_uri = $reject['object']['actor'] ?? '';
$actor_post = Remote_Actors::get_by_uri( object_to_uri( $actor_uri ) );
/*
* For a Follow Reject, the sender must be the actor that was followed.
* Without this, a signed Reject from one actor could cancel a Follow that
* targeted another actor by referencing that pending Follow's outbox GUID.
*/
$reject_actor = object_to_uri( $reject['actor'] ?? '' );
$followed_actor = object_to_uri( $reject['object']['object'] ?? '' );
if ( ! $reject_actor || ! $followed_actor || $reject_actor !== $followed_actor ) {
return;
}
$actor_post = Remote_Actors::get_by_uri( $followed_actor );
if ( \is_wp_error( $actor_post ) ) {
return;
}
$user_id = is_array( $user_ids ) ? reset( $user_ids ) : $user_ids;
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
$result = Following::reject( $actor_post, $user_id );
$success = ! \is_wp_error( $result );
@ -102,6 +112,14 @@ class Reject {
return false;
}
if ( ! \is_array( $activity['object'] ) ) {
return false;
}
if ( ! isset( $activity['object']['id'], $activity['object']['type'], $activity['object']['actor'], $activity['object']['object'] ) ) {
return false;
}
return $valid;
}
}

View File

@ -31,7 +31,24 @@ class Undo {
*/
public static function handle_undo( $activity, $user_ids ) {
$success = false;
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) );
/*
* Resolve the sender so Inbox::undo() can verify ownership. A genuinely absent actor
* maps to null (no ownership check, for programmatic callers), but an actor that is
* present yet unparseable must be rejected rather than skipping the check — passing
* null there would re-open the undo-by-id attack.
*/
$actor = isset( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
if ( isset( $activity['actor'] ) && empty( $actor ) ) {
$result = new \WP_Error(
'activitypub_undo_invalid_actor',
\__( 'The Undo activity has an invalid actor.', 'activitypub' ),
array( 'status' => 400 )
);
} else {
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ), $actor );
}
if ( $result && ! \is_wp_error( $result ) ) {
$success = true;

View File

@ -13,6 +13,7 @@ use Activitypub\Collection\Remote_Posts;
use Activitypub\Http;
use function Activitypub\is_activity_reply;
use function Activitypub\object_to_uri;
/**
* Handle Update requests.
@ -29,7 +30,7 @@ class Update {
* Handle "Update" requests.
*
* @param array $activity The Activity object.
* @param int[] $user_ids The user IDs. Always null for Update activities.
* @param int[] $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
*/
public static function handle_update( $activity, $user_ids, $activity_object ) {
@ -78,7 +79,7 @@ class Update {
* Update an Object.
*
* @param array $activity The Activity object.
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
*/
public static function update_object( $activity, $user_ids, $activity_object ) {
@ -91,6 +92,10 @@ class Update {
if ( false === $comment_data ) {
$updated = false;
} elseif ( \is_wp_error( $comment_data ) ) {
// Handled but rejected (e.g. a foreign actor): keep the failure so the
// success flag stays false and the Create fallback is not triggered.
$result = $comment_data;
} elseif ( ! empty( $comment_data['comment_ID'] ) ) {
$result = \get_comment( $comment_data['comment_ID'] );
}
@ -124,7 +129,7 @@ class Update {
* Update an Actor.
*
* @param array $activity The Activity object.
* @param int[]|null $user_ids The user IDs. Always null for Update activities.
* @param int[]|null $user_ids Local recipient user IDs (followers and addressed local actors); may be empty.
*/
public static function update_actor( $activity, $user_ids ) {
/*
@ -148,10 +153,16 @@ class Update {
}
}
if ( \is_array( $actor ) && isset( $actor['id'] ) ) {
/*
* An actor may only update itself. Bind the updated object to the activity
* actor (the same constraint the Delete handler enforces) so a remote server
* cannot overwrite another host's cached actor by sending an Update whose
* object.id points at a victim actor.
*/
if ( \is_array( $actor ) && isset( $actor['id'] ) && object_to_uri( $actor ) === object_to_uri( $activity['actor'] ) ) {
$state = Remote_Actors::upsert( $actor );
} else {
$state = new \WP_Error( 'activitypub_update_failed', 'Update failed: missing or invalid actor object in Update activity' );
$state = new \WP_Error( 'activitypub_update_failed', \__( 'Update failed: missing, invalid, or unauthorized actor object in Update activity.', 'activitypub' ) );
$actor = array();
}

View File

@ -93,7 +93,7 @@ class Arrive {
$location_name = self::get_location_name( $location );
$title = $location_name
? sprintf(
? \sprintf(
/* translators: %s: location name */
\__( 'Checked in at %s', 'activitypub' ),
$location_name