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

@ -13,6 +13,7 @@ use Activitypub\Collection\Followers;
use Activitypub\Collection\Following;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Moderation;
use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\user_can_activitypub;
@ -118,6 +119,12 @@ class Actions_Controller extends \WP_REST_Controller {
* @return bool|\WP_Error True if the request has permission, WP_Error object otherwise.
*/
public function check_permission() {
// This is an admin endpoint; scoped OAuth C2S tokens must not drive it.
$denied = OAuth_Server::deny_if_oauth();
if ( null !== $denied ) {
return $denied;
}
if ( ! user_can_activitypub( \get_current_user_id() ) ) {
return new \WP_Error(
'rest_forbidden',

View File

@ -8,6 +8,7 @@
namespace Activitypub\Rest\Admin;
use Activitypub\Collection\Actors;
use Activitypub\OAuth\Server as OAuth_Server;
use Activitypub\Statistics;
use function Activitypub\user_can_act_as_blog;
@ -65,6 +66,12 @@ class Statistics_Controller extends \WP_REST_Controller {
* @return true|\WP_Error True if the request has access, WP_Error otherwise.
*/
public function get_item_permissions_check( $request ) {
// This is an admin endpoint; scoped OAuth C2S tokens must not drive it.
$denied = OAuth_Server::deny_if_oauth();
if ( null !== $denied ) {
return $denied;
}
$user_id = (int) $request->get_param( 'user_id' );
// Check if user can access stats for this actor.

View File

@ -0,0 +1,148 @@
<?php
/**
* Actor_Autocomplete_Controller file.
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Remote_Actors;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_rest_url_by_path;
/**
* ActivityPub Actor Autocomplete Controller.
*
* Implements the SWICG ActivityPub API actor autocomplete extension: a typeahead search over the
* actors this server knows (its own local actors and the remote actors it has cached), returning an
* ActivityStreams Collection of actor objects.
*
* @see https://swicg.github.io/activitypub-api/autocomplete
*/
class Actor_Autocomplete_Controller extends \WP_REST_Controller {
use Verification;
/**
* The JSON-LD context for the actor autocomplete extension.
*
* @var string
*/
const CONTEXT = 'https://swicg.github.io/activitypub-api/autocomplete';
/**
* The namespace of this controller's route.
*
* @var string
*/
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
/**
* The base of this controller's route.
*
* @var string
*/
protected $rest_base = 'actors/autocomplete';
/**
* Register routes.
*/
public function register_routes() {
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'verify_authentication' ),
'args' => array(
'q' => array(
'description' => 'The text typed so far.',
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Search actors matching the query.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Collection of actor objects, or WP_Error on failure.
*/
public function get_items( $request ) {
$query = \trim( (string) $request->get_param( 'q' ) );
/**
* Filters the minimum number of characters required before actors are searched.
*
* @param int $min_length The minimum query length. Default 2.
*/
$min_length = (int) \apply_filters( 'activitypub_actor_autocomplete_min_length', 2 );
if ( \mb_strlen( $query ) < $min_length ) {
return new \WP_Error(
'activitypub_query_too_short',
\sprintf(
/* translators: %d: minimum number of characters */
\__( 'The search query must be at least %d characters long.', 'activitypub' ),
$min_length
),
array( 'status' => 400 )
);
}
/**
* Filters the maximum number of actors returned by the autocomplete endpoint.
*
* @param int $number The maximum number of actors. Default 10.
* @param \WP_REST_Request $request The request object.
*/
$number = \max( 1, (int) \apply_filters( 'activitypub_actor_autocomplete_number', 10, $request ) );
$items = array();
$seen = array();
foreach ( Actors::search( $query, $number ) as $actor ) {
$items[] = $actor->to_array( false );
$seen[ $actor->get_id() ] = true;
}
// Only look up as many remote actors as are still needed to fill the result set.
$remaining = $number - \count( $items );
if ( $remaining > 0 ) {
foreach ( Remote_Actors::search( $query, $remaining ) as $post ) {
$actor = Remote_Actors::get_actor( $post );
if ( \is_wp_error( $actor ) || isset( $seen[ $actor->get_id() ] ) ) {
continue;
}
$items[] = $actor->to_array( false );
$seen[ $actor->get_id() ] = true;
}
}
$response = array(
// JSON_LD_CONTEXT is already a context array, so merge the extension IRI in rather than nesting it.
'@context' => \array_merge( (array) Base_Object::JSON_LD_CONTEXT, array( self::CONTEXT ) ),
'id' => \add_query_arg( 'q', $query, get_rest_url_by_path( $this->rest_base ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'type' => 'Collection',
'totalItems' => \count( $items ),
'items' => $items,
);
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
}
}

View File

@ -10,6 +10,8 @@ namespace Activitypub\Rest;
use Activitypub\Collection\Actors as Actor_Collection;
use Activitypub\Webfinger;
use function Activitypub\get_client_ip;
/**
* ActivityPub Actors REST-Class.
*
@ -118,6 +120,23 @@ class Actors_Controller extends \WP_REST_Controller {
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_remote_follow_item( $request ) {
/*
* This endpoint is unauthenticated and triggers an outbound WebFinger request to a
* user-supplied host, so throttle it per IP (max 10 per minute) to limit its use as
* a blind SSRF / request-amplification vector. Fail closed when no IP is available.
*/
$ip = get_client_ip();
if ( '' === $ip ) {
return self::rate_limit_response();
}
$transient_key = 'ap_remote_follow_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );
if ( $count >= 10 ) {
return self::rate_limit_response();
}
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
$resource = $request->get_param( 'resource' );
$user_id = $request->get_param( 'user_id' );
$user = Actor_Collection::get_by_id( $user_id );
@ -139,6 +158,24 @@ class Actors_Controller extends \WP_REST_Controller {
);
}
/**
* Build a 429 rate-limit response for the remote-follow endpoint.
*
* @return \WP_REST_Response The rate-limit response.
*/
private static function rate_limit_response() {
return new \WP_REST_Response(
array(
'code' => 'activitypub_rate_limited',
'message' => \__( 'Too many requests. Please try again later.', 'activitypub' ),
'data' => array( 'status' => 429 ),
),
429,
// RFC 6585 §4: send Retry-After so clients can back off.
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
);
}
/**
* Retrieves the actor schema, conforming to JSON Schema.
*

View File

@ -72,19 +72,25 @@ class Actors_Inbox_Controller extends Actors_Controller {
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'id' => array(
'user_id' => array(
'description' => 'The ID of the actor.',
'type' => 'integer',
'required' => true,
'validate_callback' => array( $this, 'validate_inbox_user_id' ),
),
'id' => array(
'description' => 'The unique identifier for the activity.',
'type' => 'string',
'format' => 'uri',
'required' => true,
),
'actor' => array(
'actor' => array(
'description' => 'The actor performing the activity.',
'type' => 'string',
'required' => true,
'sanitize_callback' => '\Activitypub\object_to_uri',
),
'type' => array(
'type' => array(
'description' => 'The type of the activity.',
'type' => 'string',
'required' => true,
@ -94,7 +100,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
return '' !== \sanitize_html_class( (string) $param );
},
),
'object' => array(
'object' => array(
'description' => 'The object of the activity.',
'required' => true,
'sanitize_callback' => array( $this, 'localize_language_maps' ),
@ -141,6 +147,28 @@ class Actors_Inbox_Controller extends Actors_Controller {
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_create_item' ) );
}
/**
* Validate the user ID for inbox deliveries.
*
* Also accepts the retired Application ID so remote servers that cached the
* old Application actor document, which advertised this route as its inbox,
* can still deliver to it. Those requests are handed to the shared inbox in
* `create_item()`.
*
* @since 9.1.0
*
* @param int $user_id The user ID.
*
* @return true|\WP_Error True if the user ID is valid, WP_Error otherwise.
*/
public function validate_inbox_user_id( $user_id ) {
if ( Actors::APPLICATION_USER_ID === (int) $user_id ) {
return true;
}
return $this->validate_user_id( $user_id );
}
/**
* Retrieves a collection of inbox items.
*
@ -192,7 +220,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => $user->get_id(),
'type' => 'OrderedCollection',
@ -266,8 +294,21 @@ class Actors_Inbox_Controller extends Actors_Controller {
*/
public function create_item( $request ) {
$user_id = $request->get_param( 'user_id' );
$data = $request->get_json_params();
$type = camel_to_snake_case( $request->get_param( 'type' ) );
/*
* Deliveries to the retired Application actor's inbox come from remote
* servers that cached its actor document from before the Application was
* extracted from the actor system. Hand them to the shared inbox, which
* also rejects Follows aimed at the Application.
*/
if ( Actors::APPLICATION_USER_ID === (int) $user_id ) {
$shared_inbox = new Inbox_Controller();
return $shared_inbox->create_item( $request );
}
$data = $request->get_json_params();
$type = camel_to_snake_case( $request->get_param( 'type' ) );
/* @var Activity $activity Activity object.*/
$activity = Activity::init_from_array( $data );
@ -282,7 +323,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
* @param string $type The type of the activity.
* @param Activity|\WP_Error $activity The Activity object.
*/
do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
\do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
} else {
/**
* ActivityPub inbox action.
@ -324,7 +365,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
Inbox::add( $activity, (array) $user_id );
\wp_clear_scheduled_hook( 'activitypub_inbox_create_item', array( $activity_id ) );
\wp_schedule_single_event( time() + 15, 'activitypub_inbox_create_item', array( $activity_id ) );
\wp_schedule_single_event( \time() + 15, 'activitypub_inbox_create_item', array( $activity_id ) );
}
}

View File

@ -2,12 +2,23 @@
/**
* Application Controller file.
*
* Self-contained controller for the ActivityPub Application actor.
* The Application is not a real actor in the plugin's internal sense —
* it cannot be followed, addressed, or interacted with. It exists only as:
* 1. A JSON-LD document at /wp-json/activitypub/1.0/application
* 2. A signing identity for outbound HTTP GET requests
*
* @package Activitypub
*/
namespace Activitypub\Rest;
use Activitypub\Model\Application;
use Activitypub\Activity\Actor;
use Activitypub\Activity\Generic_Object;
use Activitypub\Application;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\home_host;
/**
* ActivityPub Application Controller.
@ -43,16 +54,105 @@ class Application_Controller extends \WP_REST_Controller {
'schema' => array( $this, 'get_item_schema' ),
)
);
\register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/outbox',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_outbox' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Retrieves the application actor profile.
*
* @since 9.1.0
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response Response object.
*/
public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$json = ( new Application() )->to_array();
$id = Application::get_id();
$json = array(
'@context' => Actor::JSON_LD_CONTEXT,
'id' => $id,
'type' => 'Application',
'name' => Application::USERNAME,
'preferredUsername' => Application::USERNAME,
'summary' => sprintf(
/* translators: %s: Domain of the site */
\__( 'This is the Application Actor for %s.', 'activitypub' ),
home_host()
),
'url' => Application::get_url(),
'icon' => Application::get_icon(),
'published' => Application::get_published(),
'inbox' => get_rest_url_by_path( 'inbox' ),
'outbox' => get_rest_url_by_path( 'application/outbox' ),
'manuallyApprovesFollowers' => true,
'discoverable' => false,
'indexable' => false,
'invisible' => true,
'webfinger' => Application::get_webfinger(),
'publicKey' => array(
'id' => Application::get_key_id(),
'owner' => $id,
'publicKeyPem' => Application::get_public_key(),
),
'implements' => array(
array(
'href' => 'https://datatracker.ietf.org/doc/html/rfc9421',
'name' => 'RFC-9421: HTTP Message Signatures',
),
),
);
/*
* Run the same serialization filters the object-based path used, so
* integrations that add actor fields via these hooks still apply to the
* Application actor. The filters document a Generic_Object argument, so
* hydrate one from the array to keep that contract.
*/
$class = 'application';
$object = Generic_Object::init_from_array( $json );
/** This filter is documented in includes/activity/class-generic-object.php */
$json = \apply_filters( 'activitypub_activity_object_array', $json, $class, $id, $object );
/** This filter is documented in includes/activity/class-generic-object.php */
$json = \apply_filters( "activitypub_activity_{$class}_object_array", $json, $id, $object );
$rest_response = new \WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $rest_response;
}
/**
* Returns an empty outbox collection for the Application actor.
*
* The Application is a signing-only identity and does not publish
* activities, so its outbox is always an empty OrderedCollection.
*
* @since 9.1.0
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response Response object.
*/
public function get_outbox( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$json = array(
'@context' => Actor::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( 'application/outbox' ),
'type' => 'OrderedCollection',
'totalItems' => 0,
'orderedItems' => array(),
);
$rest_response = new \WP_REST_Response( $json, 200 );
$rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
@ -157,6 +257,9 @@ class Application_Controller extends \WP_REST_Controller {
'indexable' => array(
'type' => 'boolean',
),
'invisible' => array(
'type' => 'boolean',
),
'implements' => array(
'type' => 'array',
'items' => array(

View File

@ -130,7 +130,7 @@ class Collections_Controller extends Actors_Controller {
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user_id ) ),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/collections/tags', $user_id ) ),
'type' => 'Collection',
'totalItems' => \is_countable( $tags ) ? \count( $tags ) : 0,
'items' => array(),
@ -139,7 +139,7 @@ class Collections_Controller extends Actors_Controller {
foreach ( $tags as $tag ) {
$response['items'][] = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $tag ) ),
'href' => \esc_url_raw( \get_tag_link( $tag ) ),
'name' => esc_hashtag( $tag->name ),
);
}
@ -161,7 +161,7 @@ class Collections_Controller extends Actors_Controller {
if ( is_single_user() || Actors::BLOG_USER_ID !== $user_id ) {
$sticky_posts = \get_option( 'sticky_posts' );
if ( $sticky_posts && is_array( $sticky_posts ) ) {
if ( $sticky_posts && \is_array( $sticky_posts ) ) {
// Only show public posts.
$args = array(
'post__in' => $sticky_posts,
@ -187,7 +187,7 @@ class Collections_Controller extends Actors_Controller {
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $user_id ) ),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/collections/featured', $user_id ) ),
'type' => 'OrderedCollection',
'totalItems' => \is_countable( $posts ) ? \count( $posts ) : 0,
'orderedItems' => array(),

View File

@ -10,6 +10,7 @@ namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Remote_Actors;
use Activitypub\Signature;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_rest_url_by_path;
@ -259,8 +260,17 @@ class Followers_Controller extends Actors_Controller {
* FEP-8fcf: the responding server MUST ensure the requested authority
* matches the signing peer, so that instances cannot "get tricked
* into requesting the followers list of a third-party individual".
*
* Derive the signer host from the keyId that the verifier actually
* checked (Signature::get_key_id() mirrors the verifier's header
* choice and returns null when several labels make the choice
* ambiguous). Re-parsing the raw headers here would diverge from the
* verified key: a request can carry an RFC 9421 Signature-Input plus a
* Signature header padded with an unrelated keyId, letting an attacker
* sign with their own key yet steer a naive parser at a third-party host.
*/
$signer_host = self::normalize_host( self::get_signer_host( $request ) );
$key_id = Signature::get_key_id( $request );
$signer_host = $key_id ? self::normalize_host( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) ) : '';
$asked_host = self::normalize_host( (string) \wp_parse_url( $authority, \PHP_URL_HOST ) );
if ( ! $signer_host || ! $asked_host || $signer_host !== $asked_host ) {
@ -279,11 +289,11 @@ class Followers_Controller extends Actors_Controller {
\sprintf(
'actors/%d/followers/sync?authority=%s',
$user_id,
rawurlencode( $authority )
\rawurlencode( $authority )
)
),
'type' => 'OrderedCollection',
'totalItems' => count( $followers ),
'totalItems' => \count( $followers ),
'orderedItems' => $followers,
);
@ -316,38 +326,6 @@ class Followers_Controller extends Actors_Controller {
return \rtrim( $host, '.' );
}
/**
* Resolve the signing peer's host from the request's HTTP Signature header.
*
* Supports both Cavage-style `Signature: keyId="…"` and RFC 9421's
* `Signature-Input: …keyid="…"`. Returns the host component of the key
* ID URI, lowercased, or an empty string when none is present.
*
* @since 8.1.0
*
* @param \WP_REST_Request $request The request object.
* @return string The signer's host, or an empty string.
*/
private static function get_signer_host( $request ) {
$signature = $request->get_header( 'signature' );
$key_id = null;
if ( $signature && \preg_match( '/keyId="([^"]+)"/i', $signature, $matches ) ) {
$key_id = $matches[1];
} else {
$signature_input = $request->get_header( 'signature-input' );
if ( $signature_input && \preg_match( '/keyid="([^"]+)"/i', $signature_input, $matches ) ) {
$key_id = $matches[1];
}
}
if ( ! $key_id ) {
return '';
}
return \strtolower( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) );
}
/**
* Retrieves the followers schema, conforming to JSON Schema.
*

View File

@ -8,7 +8,6 @@
namespace Activitypub\Rest;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Following;
use Activitypub\Collection\Remote_Actors;
@ -86,10 +85,6 @@ class Following_Controller extends Actors_Controller {
*/
public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = null;
if ( \has_filter( 'activitypub_rest_following' ) ) {
$user = Actors::get_by_id( $user_id );
}
/**
* Action triggered prior to the ActivityPub profile being created and sent to the client.
@ -133,21 +128,6 @@ class Following_Controller extends Actors_Controller {
);
}
/**
* Filter the list of following urls
*
* @param array $items The array of following urls.
* @param \Activitypub\Model\User $user The user object.
*
* @deprecated 7.1.0 Please migrate your Followings to the new internal Following structure.
*/
$items = \apply_filters_deprecated( 'activitypub_rest_following', array( array(), $user ), '7.1.0', 'Please migrate your Followings to the new internal Following structure.' );
if ( ! empty( $items ) ) {
$response['totalItems'] = count( $items );
$response['orderedItems'] = $items;
}
$response = $this->prepare_collection_response( $response, $request );
if ( \is_wp_error( $response ) ) {
return $response;

View File

@ -433,7 +433,7 @@ class Inbox_Controller extends \WP_REST_Controller {
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 ) );
$user_ids = \array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
continue;
}
@ -473,7 +473,7 @@ class Inbox_Controller extends \WP_REST_Controller {
}
if ( is_collection( $collection ) ) {
$user_ids = array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
$user_ids = \array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
continue;
}
}
@ -500,7 +500,7 @@ class Inbox_Controller extends \WP_REST_Controller {
}
}
return array_unique( array_map( 'intval', $user_ids ) );
return \array_unique( \array_map( 'intval', $user_ids ) );
}
/**

View File

@ -53,7 +53,7 @@ class Interaction_Controller extends \WP_REST_Controller {
'intent' => array(
'description' => 'The intent of the interaction, e.g., follow, reply, import.',
'type' => 'string',
'enum' => array_map( 'Activitypub\camel_to_snake_case', Activity::TYPES ),
'enum' => \array_map( 'Activitypub\camel_to_snake_case', Activity::TYPES ),
),
),
),
@ -70,14 +70,14 @@ class Interaction_Controller extends \WP_REST_Controller {
*/
public function sanitize_uri( $uri ) {
// Remove "acct:" prefix if present.
if ( str_starts_with( $uri, 'acct:' ) ) {
if ( \str_starts_with( $uri, 'acct:' ) ) {
$uri = \substr( $uri, 5 );
}
// Remove "@" prefix if present.
$uri = \ltrim( $uri, '@' );
if ( is_email( $uri ) ) {
if ( \is_email( $uri ) ) {
return \sanitize_text_field( $uri );
}
@ -100,7 +100,7 @@ class Interaction_Controller extends \WP_REST_Controller {
if ( \is_wp_error( $object ) || ! isset( $object['type'] ) ) {
// Use wp_die as this can be called from the front-end. See https://github.com/Automattic/wordpress-activitypub/pull/1149/files#r1915297109.
\wp_die(
esc_html__( 'The URL is not supported!', 'activitypub' ),
\esc_html__( 'The URL is not supported!', 'activitypub' ),
'',
array(
'response' => 400,
@ -110,7 +110,7 @@ class Interaction_Controller extends \WP_REST_Controller {
}
if ( ! empty( $object['id'] ) ) {
$uri = \esc_url( $object['id'] );
$uri = \esc_url_raw( $object['id'] );
}
// Prepare URL parameter.
@ -191,7 +191,7 @@ class Interaction_Controller extends \WP_REST_Controller {
if ( ! $redirect_url ) {
// Use wp_die as this can be called from the front-end. See https://github.com/Automattic/wordpress-activitypub/pull/1149/files#r1915297109.
\wp_die(
esc_html__( 'This Interaction type is not supported yet!', 'activitypub' ),
\esc_html__( 'This Interaction type is not supported yet!', 'activitypub' ),
'',
array(
'response' => 400,

View File

@ -89,15 +89,15 @@ class Liked_Controller extends Actors_Controller {
// Paginate the results.
$offset = ( null !== $page ) ? ( $page - 1 ) * $per_page : 0;
$ordered_items = array_slice( $liked_objects, $offset, $per_page );
$ordered_items = \array_slice( $liked_objects, $offset, $per_page );
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/liked', $user_id ) ),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/liked', $user_id ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => Actors::get_by_id( $user_id )->get_id(),
'type' => 'OrderedCollection',
'totalItems' => count( $liked_objects ),
'totalItems' => \count( $liked_objects ),
'orderedItems' => $ordered_items,
);

View File

@ -87,7 +87,7 @@ class Moderators_Controller extends \WP_REST_Controller {
*
* @param array $actors The list of moderators.
*/
$actors = apply_filters( 'activitypub_rest_moderators', $actors );
$actors = \apply_filters( 'activitypub_rest_moderators', $actors );
$response = array(
'id' => get_rest_url_by_path( 'collections/moderators' ),

View File

@ -157,7 +157,7 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
'name' => 'wordpress',
'version' => get_masked_wp_version(),
),
'openRegistrations' => (bool) get_option( 'users_can_register' ),
'openRegistrations' => (bool) \get_option( 'users_can_register' ),
'usage' => array(
'localPosts' => (int) $posts->publish,
'localComments' => $comments->approved,

View File

@ -18,7 +18,6 @@ use function Activitypub\get_masked_wp_version;
use function Activitypub\get_object_id;
use function Activitypub\get_rest_url_by_path;
use function Activitypub\object_to_uri;
use function Activitypub\user_can_act_as_blog;
/**
* ActivityPub Outbox Controller.
@ -180,23 +179,16 @@ class Outbox_Controller extends \WP_REST_Controller {
);
/*
* Whether the current user owns the outbox being queried. Owners see private
* and non-public activity types without the visibility filters below.
* Whether the current user owns the outbox being queried. Owners see private and
* non-public activity types; unauthenticated, federation, and non-owner requests are
* limited to the public subset by the visibility filter below.
*
* For the blog actor (user_id = 0) the identity-equality check is wrong on
* two counts — `get_current_user_id()` returns 0 for anonymous visitors (so
* `0 === 0` would leak everything to the public), and AP-capable authors
* would otherwise pass the `current_user_can( 'activitypub' )` arm and read
* the blog's private Accepts. Delegate to the capability helper instead.
* Reuse the canonical ownership gate (the same check the OAuth C2S path applies via
* maybe_verify_owner()) instead of re-deriving it here: it requires an authenticated
* session, matches the requested user by identity, and handles the blog actor via
* user_can_act_as_blog(). A global capability never stands in for ownership.
*/
if ( Actors::BLOG_USER_ID === (int) $user_id ) {
$is_outbox_owner = user_can_act_as_blog();
} else {
$is_outbox_owner = \is_user_logged_in() && (
\get_current_user_id() === (int) $user_id
|| \current_user_can( 'activitypub' )
);
}
$is_outbox_owner = true === $this->verify_owner( $request );
if ( ! $is_outbox_owner ) {
$args['meta_query'][] = array(
@ -233,7 +225,7 @@ class Outbox_Controller extends \WP_REST_Controller {
$response = array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ),
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
'actor' => $user->get_id(),
'type' => 'OrderedCollection',
@ -437,7 +429,7 @@ class Outbox_Controller extends \WP_REST_Controller {
// Determine if this is an Activity or a bare Object.
$type = $data['type'] ?? '';
$is_activity = in_array( $type, Activity::TYPES, true );
$is_activity = \in_array( $type, Activity::TYPES, true );
// If it's a bare object, wrap it in a Create activity.
if ( ! $is_activity ) {
@ -548,7 +540,7 @@ class Outbox_Controller extends \WP_REST_Controller {
}
}
return array_merge(
return \array_merge(
array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'type' => 'Create',
@ -593,7 +585,7 @@ class Outbox_Controller extends \WP_REST_Controller {
// Check object.attributedTo if present.
$object = $data['object'] ?? $data;
if ( is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
if ( \is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
$attributed_to = object_to_uri( $object['attributedTo'] );
if ( $attributed_to && $attributed_to !== $user_actor_id ) {
return new \WP_Error(
@ -644,12 +636,12 @@ class Outbox_Controller extends \WP_REST_Controller {
$cc = (array) ( $activity['cc'] ?? array() );
// Check if public.
if ( in_array( $public, $to, true ) ) {
if ( \in_array( $public, $to, true ) ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
}
// Check if unlisted (public in cc).
if ( in_array( $public, $cc, true ) ) {
if ( \in_array( $public, $cc, true ) ) {
return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
}
@ -670,7 +662,7 @@ class Outbox_Controller extends \WP_REST_Controller {
*/
private function ensure_object_id( $data, $user ) {
// Check if there's an embedded object that needs fields.
if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) {
if ( ! isset( $data['object'] ) || ! \is_array( $data['object'] ) ) {
return $data;
}

View File

@ -165,13 +165,14 @@ class Post_Controller extends \WP_REST_Controller {
* Decode entities first so a stored pseudo-tag like
* `&lt;img&gt;` becomes a real `<img>` for the next
* step to remove, then strip any tags so the JSON
* response contains only plain text. `esc_url()`
* rejects `javascript:` and other unsafe schemes.
* response contains only plain text. `esc_url_raw()`
* rejects `javascript:` and other unsafe schemes without
* HTML-encoding ampersands (this is JSON, not markup).
*/
return array(
'name' => \wp_strip_all_tags( \html_entity_decode( $comment->comment_author, ENT_QUOTES ) ),
'url' => \esc_url( $comment->comment_author_url ),
'avatar' => \esc_url( \get_avatar_url( $comment ) ),
'url' => \esc_url_raw( $comment->comment_author_url ),
'avatar' => \esc_url_raw( \get_avatar_url( $comment ) ),
);
},
$comments
@ -197,7 +198,7 @@ class Post_Controller extends \WP_REST_Controller {
return new \WP_Error( 'activitypub_post_not_found', \__( 'Post not found', 'activitypub' ), array( 'status' => 404 ) );
}
$response = array_merge(
$response = \array_merge(
array(
'@context' => Base_Object::JSON_LD_CONTEXT,
'id' => get_rest_url_by_path( \sprintf( 'posts/%d/context', $post_id ) ),

View File

@ -14,8 +14,6 @@ use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
use Activitypub\Webfinger;
use function Activitypub\is_actor;
/**
* Proxy Controller.
*
@ -181,7 +179,10 @@ class Proxy_Controller extends \WP_REST_Controller {
}
}
// Fall back to fetching as a generic object.
/*
* Fall back to fetching as a generic object. Actors are already resolved and
* cached above via fetch_by_various(), so this path only proxies the object.
*/
$object = Http::get_remote_object( $url );
if ( \is_wp_error( $object ) ) {
@ -192,11 +193,6 @@ class Proxy_Controller extends \WP_REST_Controller {
);
}
// If it's an actor, store it for future use.
if ( is_actor( $object ) ) {
Remote_Actors::upsert( $object );
}
$response = new \WP_REST_Response( $object, 200 );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );

View File

@ -137,7 +137,7 @@ class Replies_Controller extends \WP_REST_Controller {
}
// Prepend ActivityPub Context.
$response = array_merge( array( '@context' => Base_Object::JSON_LD_CONTEXT ), $response );
$response = \array_merge( array( '@context' => Base_Object::JSON_LD_CONTEXT ), $response );
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
@ -181,7 +181,7 @@ class Replies_Controller extends \WP_REST_Controller {
}
return array(
'id' => get_rest_url_by_path( sprintf( 'posts/%d/likes', $wp_object->ID ) ),
'id' => get_rest_url_by_path( \sprintf( 'posts/%d/likes', $wp_object->ID ) ),
'type' => 'Collection',
'totalItems' => $likes,
);
@ -203,7 +203,7 @@ class Replies_Controller extends \WP_REST_Controller {
}
return array(
'id' => get_rest_url_by_path( sprintf( 'posts/%d/shares', $wp_object->ID ) ),
'id' => get_rest_url_by_path( \sprintf( 'posts/%d/shares', $wp_object->ID ) ),
'type' => 'Collection',
'totalItems' => $shares,
);

View File

@ -7,6 +7,8 @@
namespace Activitypub\Rest;
use Activitypub\Signature;
/**
* ActivityPub Server REST-Class.
*
@ -19,6 +21,7 @@ class Server {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_filter( 'rest_pre_dispatch', array( self::class, 'maybe_add_actor_from_signature' ), 10, 3 );
\add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
\add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
@ -60,7 +63,7 @@ class Server {
if (
ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
\in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
) {
return new \WP_Error(
'activitypub_server_does_not_accept_incoming_interactions',
@ -104,6 +107,67 @@ class Server {
);
}
/**
* Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
*
* Mastodon (FEP-7aa9) omits `actor` from the FeatureRequest body and conveys the
* requesting actor only through the HTTP signature keyId. Our inbox routes require
* `actor`, so such a request is rejected during parameter validation before it can
* reach the inbox or its handler, which is why no Accept is ever sent.
*
* Derive the actor from the keyId and add it as a request parameter. The actor is
* injected with `set_param()` rather than by rewriting the request body, so the raw
* body stays byte-identical and the signed `Digest` still verifies. Inbox POSTs read
* JSON parameters first (see `request_parameter_order()`), so the value is visible to
* both parameter validation and the handler via `get_json_params()`.
*
* Scoped to FeatureRequest, the only activity type known to address this way. Runs on
* `rest_pre_dispatch` because that is the only hook that fires before required-parameter
* validation. Signature verification still runs afterwards and remains authoritative:
* the injected actor is derived from the very keyId the signature is checked against, so
* it cannot be used to impersonate another actor.
*
* @since 9.0.0
*
* @param mixed $result Response to replace the request with, or null to continue.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request The request object.
*
* @return mixed The unmodified `$result`.
*/
public static function maybe_add_actor_from_signature( $result, $server, $request ) {
// Respect an earlier short-circuit.
if ( null !== $result ) {
return $result;
}
if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
return $result;
}
$route = $request->get_route();
if (
! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
! \str_ends_with( $route, '/inbox' )
) {
return $result;
}
$json = $request->get_json_params();
if ( ! \is_array( $json ) || 'FeatureRequest' !== ( $json['type'] ?? '' ) || ! empty( $json['actor'] ) ) {
return $result;
}
$key_id = Signature::get_key_id( $request );
if ( ! $key_id ) {
return $result;
}
$request->set_param( 'actor', \strip_fragment_from_url( $key_id ) );
return $result;
}
/**
* Filters the REST API response to properly handle the ActivityPub error formatting.
*

View File

@ -153,21 +153,13 @@ class Authorization_Controller extends \WP_REST_Controller {
// Rate-limit authorization requests to prevent abuse (max 20 per minute per IP).
$ip = get_client_ip();
if ( '' === $ip ) {
return new \WP_Error(
'activitypub_rate_limit',
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
}
$transient_key = 'ap_oauth_auth_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );
if ( $count >= 20 ) {
return new \WP_Error(
'activitypub_rate_limit',
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
}
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
@ -403,4 +395,25 @@ class Authorization_Controller extends \WP_REST_Controller {
array( 'Location' => $redirect_url )
);
}
/**
* Build a 429 rate-limit response with a Retry-After header.
*
* @since 9.0.0
*
* @param string $message Translated human-readable error message.
* @return \WP_REST_Response
*/
private function rate_limit_response( $message ) {
return new \WP_REST_Response(
array(
'code' => 'activitypub_rate_limit',
'message' => $message,
'data' => array( 'status' => 429 ),
),
429,
// RFC 6585 §4: send Retry-After so clients can back off.
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
);
}
}

View File

@ -118,21 +118,13 @@ class Clients_Controller extends \WP_REST_Controller {
// Rate-limit registrations to prevent DB spam (max 10 per minute per IP).
$ip = get_client_ip();
if ( '' === $ip ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many client registration requests. Please try again later.', 'activitypub' ) );
}
$transient_key = 'ap_oauth_reg_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );
if ( $count >= 10 ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many client registration requests. Please try again later.', 'activitypub' ) );
}
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
@ -183,4 +175,25 @@ class Clients_Controller extends \WP_REST_Controller {
array( 'Content-Type' => 'application/json' )
);
}
/**
* Build a 429 rate-limit response with a Retry-After header.
*
* @since 9.0.0
*
* @param string $message Translated human-readable error message.
* @return \WP_REST_Response
*/
private function rate_limit_response( $message ) {
return new \WP_REST_Response(
array(
'code' => 'activitypub_rate_limited',
'message' => $message,
'data' => array( 'status' => 429 ),
),
429,
// RFC 6585 §4: send Retry-After so clients can back off.
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
);
}
}

View File

@ -313,11 +313,22 @@ class Token_Controller extends \WP_REST_Controller {
// Introspect the token.
$response = Token::introspect( $token );
// Scope introspection to same client: non-admin users can only
// introspect tokens belonging to the same client as their own.
/*
* Scope introspection for non-admins. An OAuth-authenticated caller may only
* introspect tokens issued to its own client; a cookie-authenticated caller may
* only introspect its own tokens. Without the cookie branch, any logged-in user
* could read metadata for any token string, because get_current_token() is null
* for cookie sessions and the same-client check was skipped entirely.
*/
if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) {
$current_token = OAuth_Server::get_current_token();
if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) {
if ( $current_token ) {
if ( $current_token->get_client_id() !== $response['client_id'] ) {
$response = array( 'active' => false );
}
} elseif ( \get_current_user_id() !== (int) $response['sub'] ) {
// `sub` is stored as a string; cast before comparing with the integer user ID.
$response = array( 'active' => false );
}
}
@ -397,18 +408,25 @@ class Token_Controller extends \WP_REST_Controller {
* @return \WP_REST_Response
*/
private function token_error( $error, $error_description, $status = 400 ) {
$headers = array(
'Content-Type' => 'application/json',
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
'Cache-Control' => 'no-store',
'Pragma' => 'no-cache',
);
// RFC 6585 §4: send Retry-After with rate-limit responses so clients can back off.
if ( 429 === $status ) {
$headers['Retry-After'] = (string) MINUTE_IN_SECONDS;
}
return new \WP_REST_Response(
array(
'error' => $error,
'error_description' => $error_description,
),
$status,
array(
'Content-Type' => 'application/json',
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
'Cache-Control' => 'no-store',
'Pragma' => 'no-cache',
)
$headers
);
}

View File

@ -126,10 +126,10 @@ trait Event_Stream {
*/
protected function stream_collection( $user_id, $collection ) {
// Allow PHP to detect client disconnects instead of auto-terminating.
ignore_user_abort( true );
\ignore_user_abort( true );
// Extend PHP execution time for long-lived SSE connections.
set_time_limit( 0 );
\set_time_limit( 0 );
$this->send_sse_headers();
@ -140,17 +140,17 @@ trait Event_Stream {
// Use Last-Event-ID if provided, otherwise start from the latest item.
$since_id = $last_event_id ? $last_event_id : $this->get_latest_item_id( $user_id, $collection );
$start = time();
$start = \time();
$this->send_sse_comment( 'connected' );
while ( ( time() - $start ) < 300 ) {
while ( ( \time() - $start ) < 300 ) {
if ( \connection_aborted() ) {
break;
}
// Check for signal transient before querying the DB.
$signal_key = sprintf( 'activitypub_sse_signal_%s_%s', $user_id, $collection );
$signal_key = \sprintf( 'activitypub_sse_signal_%s_%s', $user_id, $collection );
$signal = \get_transient( $signal_key );
if ( $signal ) {
@ -167,8 +167,8 @@ trait Event_Stream {
}
// Re-set signal if we hit the limit, so remaining items are fetched next iteration.
if ( count( $new_items ) >= 20 ) {
\set_transient( $signal_key, time(), 5 * MINUTE_IN_SECONDS );
if ( \count( $new_items ) >= 20 ) {
\set_transient( $signal_key, \time(), 5 * MINUTE_IN_SECONDS );
}
}
@ -176,7 +176,7 @@ trait Event_Stream {
$this->flush_output();
// phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_sleep -- SSE long-polling requires blocking sleep.
sleep( 5 );
\sleep( 5 );
}
$this->send_sse_comment( 'timeout' );
@ -194,10 +194,10 @@ trait Event_Stream {
* @param string $stream_url The remote eventStream URL.
*/
protected function relay_remote_stream( $stream_url ) {
ignore_user_abort( true );
\ignore_user_abort( true );
// Extend PHP execution time for long-lived SSE connections.
set_time_limit( 0 );
\set_time_limit( 0 );
$parsed = \wp_parse_url( $stream_url );
$host = $parsed['host'];
@ -227,7 +227,7 @@ trait Event_Stream {
exit;
}
$context = stream_context_create(
$context = \stream_context_create(
array(
'ssl' => array(
'verify_peer' => true,
@ -238,10 +238,10 @@ trait Event_Stream {
)
);
$target = ( false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip ) . ':' . $port;
$target = ( false !== \strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip ) . ':' . $port;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_stream_socket_client -- SSE proxy requires raw streaming.
$stream = stream_socket_client(
$stream = \stream_socket_client(
'ssl://' . $target,
$errno,
$errstr,
@ -272,20 +272,20 @@ trait Event_Stream {
$request_headers .= "\r\n";
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Raw stream operation.
fwrite( $stream, $request_headers );
\fwrite( $stream, $request_headers );
// Read and skip the HTTP response headers.
$header_complete = false;
$status_code = 0;
while ( ! feof( $stream ) ) {
$line = fgets( $stream, 8192 );
while ( ! \feof( $stream ) ) {
$line = \fgets( $stream, 8192 );
if ( false === $line ) {
break;
}
if ( ! $status_code && preg_match( '/^HTTP\/\d\.\d (\d{3})/', $line, $matches ) ) {
if ( ! $status_code && \preg_match( '/^HTTP\/\d\.\d (\d{3})/', $line, $matches ) ) {
$status_code = (int) $matches[1];
}
@ -298,7 +298,7 @@ trait Event_Stream {
if ( ! $header_complete || 200 !== $status_code ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Raw stream operation.
fclose( $stream );
\fclose( $stream );
\status_header( 502 );
\header( 'Content-Type: application/json' );
Server::send_cors_headers();
@ -315,19 +315,19 @@ trait Event_Stream {
$this->send_sse_headers();
$this->send_sse_comment( 'proxying ' . $host );
$start = time();
$start = \time();
stream_set_timeout( $stream, 10 );
\stream_set_timeout( $stream, 10 );
while ( ! feof( $stream ) && ( time() - $start ) < 300 ) {
while ( ! \feof( $stream ) && ( \time() - $start ) < 300 ) {
if ( \connection_aborted() ) {
break;
}
$line = fgets( $stream, 8192 );
$line = \fgets( $stream, 8192 );
if ( false === $line ) {
$meta = stream_get_meta_data( $stream );
$meta = \stream_get_meta_data( $stream );
if ( ! empty( $meta['timed_out'] ) ) {
$this->send_sse_comment( 'keepalive ' . \gmdate( 'c' ) );
@ -344,7 +344,7 @@ trait Event_Stream {
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Raw stream operation.
fclose( $stream );
\fclose( $stream );
$this->send_sse_comment( 'proxy timeout' );
$this->flush_output();
@ -356,8 +356,8 @@ trait Event_Stream {
* Send SSE-specific HTTP headers.
*/
protected function send_sse_headers() {
while ( ob_get_level() > 0 ) {
ob_end_clean();
while ( \ob_get_level() > 0 ) {
\ob_end_clean();
}
\status_header( 200 );
@ -405,10 +405,10 @@ trait Event_Stream {
* Flush all output buffers.
*/
protected function flush_output() {
if ( ob_get_level() > 0 ) {
ob_flush();
if ( \ob_get_level() > 0 ) {
\ob_flush();
}
flush();
\flush();
}
/**
@ -480,6 +480,7 @@ trait Event_Stream {
);
if ( 'outbox' === $collection ) {
$args['author'] = $user_id > 0 ? $user_id : null;
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$args['meta_query'] = array(
array(
@ -511,7 +512,7 @@ trait Event_Stream {
* @return string The eventStream URL.
*/
public function get_stream_url( $user_id, $collection ) {
return \rest_url( sprintf( '%s/actors/%d/%s/stream', $this->namespace, $user_id, $collection ) );
return \rest_url( \sprintf( '%s/actors/%d/%s/stream', $this->namespace, $user_id, $collection ) );
}
/**
@ -536,6 +537,7 @@ trait Event_Stream {
);
if ( 'outbox' === $collection ) {
$args['author'] = $user_id > 0 ? $user_id : null;
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$args['meta_query'] = array(
array(

View File

@ -71,17 +71,17 @@ trait Verification {
// POST-Requests always have to be signed, GET-Requests only require a signature in secure mode or when forced.
if ( 'GET' !== $request->get_method() || use_authorized_fetch() || $force_signature ) {
$verified_request = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_request ) ) {
$verified_key_id = Signature::verify_http_signature( $request );
if ( \is_wp_error( $verified_key_id ) ) {
return new \WP_Error(
'activitypub_signature_verification',
$verified_request->get_error_message(),
$verified_key_id->get_error_message(),
array( 'status' => 401 )
);
}
// Verify the signing key's host matches the activity actor's host.
$key_id_check = $this->verify_key_id( $request );
$key_id_check = $this->verify_key_id( $request, $verified_key_id );
if ( \is_wp_error( $key_id_check ) ) {
return $key_id_check;
}
@ -93,22 +93,24 @@ trait Verification {
/**
* Check that the signature keyId and activity actor share the same host.
*
* Binds against the keyId that {@see Signature::verify_http_signature()} actually
* verified, passed in by the caller. Re-parsing the headers here would be unsafe: a
* request can present several signature labels (or a draft and an RFC 9421 header) with
* different keyIds, and only the verifier knows which one validated.
*
* @since 8.1.0
* @since 9.0.0 Added the `$key_id` parameter; binds against the verified keyId.
*
* @param \WP_REST_Request $request The request object.
* @param string|null $key_id The keyId that verified the signature.
* @return true|\WP_Error True if valid, WP_Error on mismatch.
*/
private function verify_key_id( $request ) {
$sig = $request->get_header( 'signature' );
if ( ! $sig || ! \preg_match( '/keyId="([^"]+)"/i', $sig, $m ) ) {
// RFC 9421 Signature-Input.
$sig = $request->get_header( 'signature-input' );
if ( ! $sig || ! \preg_match( '/keyid="([^"]+)"/i', $sig, $m ) ) {
return true;
}
private function verify_key_id( $request, $key_id ) {
if ( ! $key_id ) {
return true;
}
$key_host = \strtolower( (string) \wp_parse_url( $m[1], \PHP_URL_HOST ) );
$key_host = \strtolower( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) );
$json = $request->get_json_params();
$actor = isset( $json['actor'] ) ? object_to_uri( $json['actor'] ) : null;