updated plugin ActivityPub version 9.1.0
This commit is contained in:
@ -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',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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' ),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -165,13 +165,14 @@ class Post_Controller extends \WP_REST_Controller {
|
||||
* Decode entities first so a stored pseudo-tag like
|
||||
* `<img>` 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 ) ),
|
||||
|
||||
@ -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' ) );
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user