From 05bdcfff7bfd104fa6abfa3d6e51255c3da4536c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:00 +0200 Subject: [PATCH 01/19] Add seek plumbing to the collection trait --- includes/rest/trait-collection.php | 118 ++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 5258389ee3..45a818aa98 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -7,6 +7,8 @@ namespace Activitypub\Rest; +use function Activitypub\get_rest_url_by_path; + /** * Collection Trait. * @@ -14,6 +16,15 @@ * and type transitions between Collection and CollectionPage. */ trait Collection { + /** + * The JSON-LD context for the seekItem collection extension. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @var string + */ + private $seek_item_context = 'https://purl.archive.org/socialweb/seekitem/1.0'; + /** * The JSON-LD context for ActivityPub collections. * @@ -54,12 +65,27 @@ public function prepare_collection_response( $response, $request ) { $response = array( '@context' => $this->json_ld_context ) + $response; } + // Advertise the seek endpoint on Collections whose controller can resolve an item to a page. + if ( null === $page && \method_exists( $this, 'get_item_index' ) ) { + $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $response['id'] ), get_rest_url_by_path( 'seek' ) ); + + if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { + $response['@context'][] = $this->seek_item_context; + } elseif ( \is_string( $response['@context'] ) ) { + $response['@context'] = array( $response['@context'], $this->seek_item_context ); + } + } + if ( empty( $response['items'] ) && empty( $response['orderedItems'] ) ) { // Skip pagination metadata when items are intentionally hidden or collection is empty. return $response; } - $response['id'] = \add_query_arg( $request->get_query_params(), $response['id'] ); + // The `item` seek parameter is handled before the collection is built and must not leak into navigation links. + $query_params = $request->get_query_params(); + unset( $query_params['item'] ); + + $response['id'] = \add_query_arg( $query_params, $response['id'] ); $response['first'] = \add_query_arg( 'page', 1, $response['id'] ); $response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] ); @@ -86,6 +112,91 @@ public function prepare_collection_response( $response, $request ) { return $response; } + /** + * Argument definition for the seek `item` parameter. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @return array The argument definition. + */ + public function get_seek_item_arg() { + return array( + 'description' => 'The ActivityPub object ID of an item to seek. The response redirects to the collection page containing the item.', + 'type' => 'string', + 'format' => 'uri', + ); + } + + /** + * Handle a seek request by redirecting to the collection page that contains the sought item. + * + * Implements the seekItem collection extension: when the `item` parameter is present, the + * controller's get_item_index() resolves the item's position under the exact query and + * visibility rules of the collection, and the response is a temporary redirect whose + * Location is the id of the CollectionPage containing the item. A temporary redirect is + * used because the collections are ordered newest-first, so items drift across pages as + * new items arrive. Unknown, invisible, and unauthorized items all produce the same 404, + * so collection membership is not leaked. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @param \WP_REST_Request $request The request object. + * @param string $collection_id The plain collection ID (URL without query arguments). + * + * @return \WP_REST_Response|\WP_Error|null Redirect response, 404 error, or null when this is not a seek request. + */ + public function maybe_seek_item( $request, $collection_id ) { + $item = $request->get_param( 'item' ); + if ( empty( $item ) ) { + return null; + } + + $index = $this->get_item_index( $item, $request ); + + if ( \is_wp_error( $index ) || false === $index ) { + return new \WP_Error( + 'activitypub_item_not_found', + \__( 'The requested item could not be found in this collection.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + $per_page = \max( 1, \absint( $request->get_param( 'per_page' ) ) ); + $page = (int) \floor( $index / $per_page ) + 1; + + $query_params = $request->get_query_params(); + unset( $query_params['item'] ); + $query_params['page'] = $page; + + $response = new \WP_REST_Response( null, 307 ); + $response->header( 'Location', \add_query_arg( $query_params, $collection_id ) ); + + return $response; + } + + /** + * Run a callback with an additional WHERE clause appended to WP_Query's SQL. + * + * Used by get_item_index() implementations to count the items that sort before the sought + * item, reusing the collection's own query (and thereby its visibility rules) unchanged. + * + * @param string $where Prepared SQL to append to the WHERE clause. + * @param callable $callback Callback executing the query. + * + * @return mixed The callback return value. + */ + private function with_posts_where( $where, $callback ) { + $filter = static function ( $sql ) use ( $where ) { + return $sql . $where; + }; + + \add_filter( 'posts_where', $filter ); + $result = $callback(); + \remove_filter( 'posts_where', $filter ); + + return $result; + } + /** * Get the schema for an ActivityPub Collection. * @@ -150,6 +261,11 @@ public function get_collection_schema( $item_schema = array() ) { 'type' => 'string', 'format' => 'uri', ), + 'seekItem' => array( + 'description' => 'Endpoint to resolve the collection page containing a given item.', + 'type' => 'string', + 'format' => 'uri', + ), ), ); From aa83e356c8ef1e7129971da3620786bc13a01068 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:01 +0200 Subject: [PATCH 02/19] Add a seek endpoint that dispatches to the collections internally --- activitypub.php | 1 + includes/rest/class-seek-controller.php | 131 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 includes/rest/class-seek-controller.php diff --git a/activitypub.php b/activitypub.php index b007145d90..f72f01adb8 100644 --- a/activitypub.php +++ b/activitypub.php @@ -71,6 +71,7 @@ function rest_init() { } ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); + ( new Rest\Seek_Controller() )->register_routes(); ( new Rest\Replies_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php new file mode 100644 index 0000000000..d3eb7ddf01 --- /dev/null +++ b/includes/rest/class-seek-controller.php @@ -0,0 +1,131 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'collection' => array( + 'description' => 'The ID of the collection to seek in.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'item' => array( + 'description' => 'The ActivityPub object ID of the item to seek.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Check permissions for a seek request. + * + * Supports both authentication methods commonly used with ActivityPub: a request carrying + * an OAuth 2.0 Bearer token goes through the same verify_authentication() used by the + * Client-to-Server endpoints; all other requests go through HTTP Signature verification, + * which allows anonymous reads unless Authorized Fetch is enabled. The dispatched + * collection additionally applies its own permission check, so a seek can never see more + * than the collection itself would reveal. + * + * @param \WP_REST_Request $request Full details about the request. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( OAuth_Server::is_oauth_request() ) { + return $this->verify_authentication( $request ); + } + + return $this->verify_signature( $request ); + } + + /** + * Seek an item in a collection. + * + * Dispatches an internal request to the collection endpoint with the `item` parameter and + * passes its redirect through. Unknown collections, foreign URLs, items that are not part + * of the collection, and unauthorized requests all produce the same 404, so collection + * membership is not leaked. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Redirect response on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $not_found = new \WP_Error( + 'activitypub_item_not_found', + \__( 'The requested item could not be found in this collection.', 'activitypub' ), + array( 'status' => 404 ) + ); + + // Resolves only URLs served by this site's REST API, so no remote request is ever made. + $collection_request = \WP_REST_Request::from_url( $request->get_param( 'collection' ) ); + + if ( ! $collection_request || ! \str_starts_with( $collection_request->get_route(), '/' . ACTIVITYPUB_REST_NAMESPACE . '/' ) ) { + return $not_found; + } + + $collection_request->set_param( 'item', $request->get_param( 'item' ) ); + // Carry the original headers over, so a Bearer token reaches the collection's own permission check. + $collection_request->set_headers( $request->get_headers() ); + + // The signature was verified for this request; the internal dispatch cannot carry it over. + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + $response = \rest_do_request( $collection_request ); + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { + return $response; + } + + return $not_found; + } +} From 13e11a0480cba51061c0052019095ff90744d818 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:03 +0200 Subject: [PATCH 03/19] Resolve seek requests in the followers, following, outbox, inbox, and liked collections --- .../rest/class-actors-inbox-controller.php | 115 ++++++++--- includes/rest/class-followers-controller.php | 56 ++++++ includes/rest/class-following-controller.php | 56 ++++++ includes/rest/class-liked-controller.php | 20 ++ includes/rest/class-outbox-controller.php | 188 ++++++++++++------ 5 files changed, 353 insertions(+), 82 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index b834232f93..04751c6df2 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -64,6 +64,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), 'schema' => array( $this, 'get_collection_schema' ), ), @@ -176,7 +177,6 @@ public function validate_inbox_user_id( $user_id ) { * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $page = $request->get_param( 'page' ) ?? 1; $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); @@ -191,29 +191,12 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_inbox_pre', $request ); - $args = array( - 'posts_per_page' => $request->get_param( 'per_page' ), - 'paged' => $page, - 'post_type' => Inbox::POST_TYPE, - 'post_status' => 'publish', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_user_id', - 'value' => $user_id, - ), - ), - ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } - /** - * Filters WP_Query arguments when querying Inbox items via the REST API. - * - * Enables adding extra arguments or setting defaults for an inbox collection request. - * - * @param array $args Array of arguments for WP_Query. - * @param \WP_REST_Request $request The REST API request. - */ - $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + $args = $this->get_query_args( $request ); $inbox_query = new \WP_Query(); $query_result = $inbox_query->query( $args ); @@ -272,6 +255,92 @@ public function get_items( $request ) { return $response; } + /** + * Build the WP_Query arguments for the inbox collection. + * + * Shared by get_items() and get_item_index(), so the seek index is computed under the + * exact same query rules as the collection itself. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @return array The WP_Query arguments. + */ + private function get_query_args( $request ) { + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'paged' => $request->get_param( 'page' ) ?? 1, + 'post_type' => Inbox::POST_TYPE, + 'post_status' => 'publish', + // Deterministic ordering: break post_date ties by ID, so pagination and seek agree. + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_user_id', + 'value' => $request->get_param( 'user_id' ), + ), + ), + ); + + /** + * Filters WP_Query arguments when querying Inbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an inbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + return \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + } + + /** + * Get the position of an activity in the inbox, under the collection's own query rules. + * + * @param string $item The ActivityPub activity ID. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; + + $inbox_item = Inbox::get_by_guid( $item ); + if ( \is_wp_error( $inbox_item ) ) { + return $inbox_item; + } + + $args = $this->get_query_args( $request ); + $args['fields'] = 'ids'; + $args['posts_per_page'] = 1; + unset( $args['paged'] ); + + // Confirm the item is part of this inbox before computing the index. + $membership = new \WP_Query( \array_merge( $args, array( 'post__in' => array( $inbox_item->ID ) ) ) ); + if ( ! $membership->found_posts ) { + return false; + } + + $where = $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $inbox_item->post_date, + $inbox_item->post_date, + $inbox_item->ID + ); + + // Count the activities that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $args ) { + return new \WP_Query( $args ); + } + ); + + return (int) $preceding->found_posts; + } + /** * Prepares the item for the REST response. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index cc1e5303b4..99aa78661d 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -71,6 +71,7 @@ public function register_routes() { 'default' => 'simple', 'enum' => array( 'simple', 'full' ), ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_item_schema' ), @@ -185,6 +186,11 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_followers_pre' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/followers', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $order = $request->get_param( 'order' ); $per_page = $request->get_param( 'per_page' ); $page = $request->get_param( 'page' ) ?? 1; @@ -233,6 +239,56 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a follower in the collection, under the collection's own query rules. + * + * @param string $item The ActivityPub actor ID of the follower. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; + + if ( ! $this->show_social_graph( $request ) ) { + return false; + } + + $actor = Remote_Actors::get_by_uri( $item ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + $user_id = $request->get_param( 'user_id' ); + $order = $request->get_param( 'order' ); + $args = array( + 'fields' => 'ids', + 'order' => \ucwords( $order ), + ); + + // Confirm membership through the collection's own query before computing the index. + $membership = Followers::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) ); + if ( ! $membership['total'] ) { + return false; + } + + if ( 'asc' === $order ) { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); + } else { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID ); + } + + // Count the followers that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $user_id, $args ) { + return Followers::query( $user_id, 1, null, $args ); + } + ); + + return (int) $preceding['total']; + } + /** * Retrieves partial followers list for FEP-8fcf synchronization. * diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 165de981e3..0ddcb8e7e5 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -70,6 +70,7 @@ public function register_routes() { 'default' => 'simple', 'enum' => array( 'simple', 'full' ), ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_public_item_schema' ), @@ -91,6 +92,11 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_following_pre' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/following', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $order = $request->get_param( 'order' ); $per_page = $request->get_param( 'per_page' ); $page = $request->get_param( 'page' ) ?? 1; @@ -139,6 +145,56 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a followed actor in the collection, under the collection's own query rules. + * + * @param string $item The ActivityPub actor ID of the followed actor. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; + + if ( ! $this->show_social_graph( $request ) ) { + return false; + } + + $actor = Remote_Actors::get_by_uri( $item ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + $user_id = $request->get_param( 'user_id' ); + $order = $request->get_param( 'order' ); + $args = array( + 'fields' => 'ids', + 'order' => \ucwords( $order ), + ); + + // Confirm membership through the collection's own query before computing the index. + $membership = Following::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) ); + if ( ! $membership['total'] ) { + return false; + } + + if ( 'asc' === $order ) { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); + } else { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID ); + } + + // Count the followed actors that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $user_id, $args ) { + return Following::query( $user_id, 1, null, $args ); + } + ); + + return (int) $preceding['total']; + } + /** * Retrieves the following schema, conforming to JSON Schema. * diff --git a/includes/rest/class-liked-controller.php b/includes/rest/class-liked-controller.php index 15fb253aca..36b4458dbc 100644 --- a/includes/rest/class-liked-controller.php +++ b/includes/rest/class-liked-controller.php @@ -61,6 +61,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_item_schema' ), @@ -85,6 +86,11 @@ public function get_items( $request ) { $page = $request->get_param( 'page' ); $per_page = $request->get_param( 'per_page' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/liked', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $liked_objects = $this->get_liked_object_ids( $user_id ); // Paginate the results. @@ -113,6 +119,20 @@ public function get_items( $request ) { return $response; } + /** + * Get the position of an object in the liked collection. + * + * @param string $item The ActivityPub object ID of the liked object. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false Zero-based index of the item, false when not found. + */ + public function get_item_index( $item, $request ) { + $index = \array_search( $item, $this->get_liked_object_ids( $request->get_param( 'user_id' ) ), true ); + + return false === $index ? false : (int) $index; + } + /** * Get all currently liked object IDs for an actor. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 7f55238e9b..bcc6763d45 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -86,6 +86,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), ), array( @@ -144,7 +145,6 @@ public function validate_user_id( $user_id ) { * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $page = $request->get_param( 'page' ) ?? 1; $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); @@ -155,6 +155,90 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_outbox_pre', $request ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + + $args = $this->get_query_args( $request ); + $outbox_query = new \WP_Query(); + $query_result = $outbox_query->query( $args ); + + $response = array( + '@context' => Base_Object::JSON_LD_CONTEXT, + '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', + 'totalItems' => (int) $outbox_query->found_posts, + 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ), + 'orderedItems' => array(), + ); + + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $outbox_item ) { + if ( ! $outbox_item instanceof \WP_Post ) { + /** + * Action triggered when an outbox item is not a WP_Post. + * + * @param mixed $outbox_item The outbox item. + * @param array $args The arguments used to query the outbox. + * @param array $query_result The result of the query. + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); + + continue; + } + + $item = $this->prepare_item_for_response( $outbox_item, $request ); + + if ( \is_wp_error( $item ) ) { + continue; + } + + $response['orderedItems'][] = $item; + } + + $response = $this->prepare_collection_response( $response, $request ); + if ( \is_wp_error( $response ) ) { + return $response; + } + + /** + * Filter the ActivityPub outbox array. + * + * @param array $response The ActivityPub outbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub profile has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Build the WP_Query arguments for the outbox collection. + * + * Shared by get_items() and get_item_index(), so the seek index is computed under the + * exact same visibility rules as the collection itself. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @return array The WP_Query arguments. + */ + private function get_query_args( $request ) { + $user_id = $request->get_param( 'user_id' ); + /** * Filters the activity types included in the outbox collection. * @@ -165,9 +249,14 @@ public function get_items( $request ) { $args = array( 'posts_per_page' => $request->get_param( 'per_page' ), 'author' => $user_id > 0 ? $user_id : null, - 'paged' => $page, + 'paged' => $request->get_param( 'page' ) ?? 1, 'post_type' => Outbox::POST_TYPE, 'post_status' => 'any', + // Deterministic ordering: break post_date ties by ID, so pagination and seek agree. + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( @@ -218,71 +307,52 @@ public function get_items( $request ) { * @param array $args Array of arguments for WP_Query. * @param \WP_REST_Request $request The REST API request. */ - $args = \apply_filters( 'activitypub_rest_outbox_query', $args, $request ); - - $outbox_query = new \WP_Query(); - $query_result = $outbox_query->query( $args ); - - $response = array( - '@context' => Base_Object::JSON_LD_CONTEXT, - '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', - 'totalItems' => (int) $outbox_query->found_posts, - 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ), - 'orderedItems' => array(), - ); - - \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); - foreach ( $query_result as $outbox_item ) { - if ( ! $outbox_item instanceof \WP_Post ) { - /** - * Action triggered when an outbox item is not a WP_Post. - * - * @param mixed $outbox_item The outbox item. - * @param array $args The arguments used to query the outbox. - * @param array $query_result The result of the query. - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); - - continue; - } - - $item = $this->prepare_item_for_response( $outbox_item, $request ); + return \apply_filters( 'activitypub_rest_outbox_query', $args, $request ); + } - if ( \is_wp_error( $item ) ) { - continue; - } + /** + * Get the position of an activity in the outbox, under the collection's own query rules. + * + * @param string $item The ActivityPub activity ID. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; - $response['orderedItems'][] = $item; + $outbox_item = Outbox::get_by_guid( $item ); + if ( \is_wp_error( $outbox_item ) ) { + return $outbox_item; } - $response = $this->prepare_collection_response( $response, $request ); - if ( \is_wp_error( $response ) ) { - return $response; - } + $args = $this->get_query_args( $request ); + $args['fields'] = 'ids'; + $args['posts_per_page'] = 1; + unset( $args['paged'] ); - /** - * Filter the ActivityPub outbox array. - * - * @param array $response The ActivityPub outbox array. - * @param \WP_REST_Request $request The request object. - */ - $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + // Confirm the item is visible through the collection's own query before computing the index. + $membership = new \WP_Query( \array_merge( $args, array( 'post__in' => array( $outbox_item->ID ) ) ) ); + if ( ! $membership->found_posts ) { + return false; + } - /** - * Action triggered after the ActivityPub profile has been created and sent to the client. - * - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_outbox_post', $request ); + $where = $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $outbox_item->post_date, + $outbox_item->post_date, + $outbox_item->ID + ); - $response = \rest_ensure_response( $response ); - $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + // Count the activities that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $args ) { + return new \WP_Query( $args ); + } + ); - return $response; + return (int) $preceding->found_posts; } /** From 2cf2236508898120696b82c2439c417a90a4b045 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:05 +0200 Subject: [PATCH 04/19] Add seek endpoint tests, changelog, and FEDERATION.md entry --- .github/changelog/add-seek-item-endpoint | 4 + FEDERATION.md | 1 + .../rest/class-test-seek-controller.php | 322 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .github/changelog/add-seek-item-endpoint create mode 100644 tests/phpunit/tests/includes/rest/class-test-seek-controller.php diff --git a/.github/changelog/add-seek-item-endpoint b/.github/changelog/add-seek-item-endpoint new file mode 100644 index 0000000000..e0b6bce301 --- /dev/null +++ b/.github/changelog/add-seek-item-endpoint @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Fediverse apps can now jump directly to the page of a collection that contains a specific item, instead of paging through from the start. diff --git a/FEDERATION.md b/FEDERATION.md index d0c2b4ff78..3c2f9fb58a 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -7,6 +7,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio - [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) - [ActivityPub API: Basic Profile](https://swicg.github.io/activitypub-api/basicprofile) (Client-to-Server, partial; see [OAuth 2.0 for Client-to-Server](#oauth-20-for-client-to-server)) - [ActivityPub API: Server-Sent Events](https://swicg.github.io/activitypub-api/sse) (partial, see below) +- [ActivityPub API: Seek Item](https://swicg.github.io/activitypub-api/seekitem) (followers, following, outbox, inbox, and liked collections) - [WebFinger](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/) - [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/) - [NodeInfo](https://nodeinfo.diaspora.software/) diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php new file mode 100644 index 0000000000..0c8b98d706 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -0,0 +1,322 @@ +post->create( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => 'https://example.org/actor/' . $i, + 'post_content' => \wp_slash( + \wp_json_encode( + array( + 'id' => 'https://example.org/actor/' . $i, + 'type' => 'Person', + 'preferredUsername' => 'user' . $i, + ) + ) + ), + 'meta_input' => array( + Followers::FOLLOWER_META_KEY => '0', + ), + ) + ); + } + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek', $routes ); + } + + /** + * A seek on the collection itself redirects to the page containing the item. + * + * Followers are ordered by post ID descending, so the last-created follower (actor/25) is + * the first item. With 10 items per page, actor/13 has twelve followers before it and lives + * on page two. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_item_param_redirects_to_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + + $location = $response->get_headers()['Location']; + $this->assertStringContainsString( 'page=2', $location ); + $this->assertStringContainsString( 'per_page=10', $location ); + $this->assertStringNotContainsString( 'item=', $location ); + } + + /** + * The first item of the collection resolves to page one, the last one to the last page. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_page_boundaries() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/25' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/1' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=3', $response->get_headers()['Location'] ); + } + + /** + * Ascending order inverts the index math. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_respects_order() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/1' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'order', 'asc' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + } + + /** + * An unknown item produces a 404. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_unknown_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/does-not-exist' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * A hidden social graph produces the same 404 as an unknown item. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_hidden_social_graph() { + \update_option( 'activitypub_hide_social_graph', '1' ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + \delete_option( 'activitypub_hide_social_graph' ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * The collection advertises the seek endpoint with the seekItem property and context. + * + * @covers \Activitypub\Rest\Collection::prepare_collection_response + */ + public function test_collection_advertises_seek_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $response = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertArrayHasKey( 'seekItem', $response ); + + \parse_str( (string) \wp_parse_url( $response['seekItem'], PHP_URL_QUERY ), $params ); + $this->assertStringContainsString( 'activitypub/1.0/seek', \rawurldecode( $response['seekItem'] ) ); + $this->assertSame( get_rest_url_by_path( 'actors/0/followers' ), $params['collection'] ); + $this->assertContains( 'https://purl.archive.org/socialweb/seekitem/1.0', $response['@context'] ); + } + + /** + * The seek endpoint declares its collection and item arguments. + * + * @covers ::register_routes + */ + public function test_get_item_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $response = rest_get_server()->dispatch( $request )->get_data(); + + $args = $response['endpoints'][0]['args']; + + $this->assertArrayHasKey( 'collection', $args ); + $this->assertTrue( $args['collection']['required'] ); + $this->assertArrayHasKey( 'item', $args ); + $this->assertTrue( $args['item']['required'] ); + } + + /** + * The seek endpoint dispatches to the collection and passes the redirect through. + * + * @covers ::get_item + */ + public function test_get_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', get_rest_url_by_path( 'actors/0/followers' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + } + + /** + * The advertised seekItem URL round-trips: the collection parameter it carries resolves back + * to the collection. + * + * @covers ::get_item + */ + public function test_seek_endpoint_advertised_url_round_trips() { + $collection_request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $collection_response = rest_get_server()->dispatch( $collection_request )->get_data(); + + $query = \wp_parse_url( $collection_response['seekItem'], PHP_URL_QUERY ); + \parse_str( (string) $query, $params ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $params['collection'] ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + } + + /** + * Foreign and non-ActivityPub collection URLs produce a 404. + * + * @covers ::get_item + */ + public function test_seek_endpoint_rejects_unknown_collections() { + // A remote URL never dispatches. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', 'https://remote.example/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + + // A local REST URL outside the ActivityPub namespace never dispatches. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', \rest_url( 'wp/v2/posts' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * A collection without seek support produces a 404 instead of the collection body. + * + * @covers ::get_item + */ + public function test_seek_endpoint_collection_without_seek_support() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', get_rest_url_by_path( 'collections/moderators' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * The seek of an outbox activity respects the visibility rules for unauthenticated requests. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_collection_seek_respects_visibility() { + $public_id = 'https://example.org/outbox/public-activity'; + $private_id = 'https://example.org/outbox/private-activity'; + + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'blog', + '_activitypub_activity_type' => 'Create', + ), + ); + + $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $follow = $create; + $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; + self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); + + // The public activity is seekable anonymously. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $public_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + + // The non-public activity type is invisible to anonymous seeks. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $private_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + + \wp_delete_post( $public_post, true ); + } +} From 3ac11b5bc82c61be1cee9c79bf4ffdf9a90b626c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:13:08 +0200 Subject: [PATCH 05/19] Honor forced signatures on seek, preserve collection query args, add @since tags --- .../rest/class-actors-inbox-controller.php | 2 + includes/rest/class-followers-controller.php | 2 + includes/rest/class-following-controller.php | 2 + includes/rest/class-liked-controller.php | 2 + includes/rest/class-outbox-controller.php | 2 + includes/rest/class-seek-controller.php | 18 +++++-- includes/rest/trait-collection.php | 27 ++++++++--- .../rest/class-test-seek-controller.php | 47 ++++++++++++++++++- 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 04751c6df2..46bbf3f1f4 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,6 +299,8 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 99aa78661d..93dcb86347 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -242,6 +242,8 @@ static function ( $item ) use ( $context ) { /** * Get the position of a follower in the collection, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub actor ID of the follower. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 0ddcb8e7e5..3d4a6124bd 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -148,6 +148,8 @@ static function ( $item ) use ( $context ) { /** * Get the position of a followed actor in the collection, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub actor ID of the followed actor. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-liked-controller.php b/includes/rest/class-liked-controller.php index 36b4458dbc..e50dfd6e12 100644 --- a/includes/rest/class-liked-controller.php +++ b/includes/rest/class-liked-controller.php @@ -122,6 +122,8 @@ public function get_items( $request ) { /** * Get the position of an object in the liked collection. * + * @since unreleased + * * @param string $item The ActivityPub object ID of the liked object. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index bcc6763d45..4fbcaaa996 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -313,6 +313,8 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the outbox, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index d3eb7ddf01..1854405611 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -18,6 +18,8 @@ * collection and every current and future collection is reachable through one endpoint. * * @see https://swicg.github.io/activitypub-api/seekitem + * + * @since unreleased */ class Seek_Controller extends \WP_REST_Controller { use Verification; @@ -117,10 +119,20 @@ public function get_item( $request ) { // Carry the original headers over, so a Bearer token reaches the collection's own permission check. $collection_request->set_headers( $request->get_headers() ); - // The signature was verified for this request; the internal dispatch cannot carry it over. - \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + /* + * The outer request's signature was verified for the /seek route and cannot be replayed + * against the inner route, so defer signature verification for the dispatch. Endpoints that + * force signatures (FEP-8fcf's /followers/sync) are deliberately not deferred: their + * mandatory verification must still run, and it will fail for a seek since the signature was + * never computed over their route. get_item() maps that failure to the same 404 as any other + * non-seekable collection, so no membership is leaked. + */ + $defer = static function ( $deferred, $inner_request, $force_signature ) { + return $force_signature ? $deferred : true; + }; + \add_filter( 'activitypub_defer_signature_verification', $defer, 10, 3 ); $response = \rest_do_request( $collection_request ); - \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { return $response; diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 45a818aa98..aae60d4aca 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -65,9 +65,22 @@ public function prepare_collection_response( $response, $request ) { $response = array( '@context' => $this->json_ld_context ) + $response; } - // Advertise the seek endpoint on Collections whose controller can resolve an item to a page. - if ( null === $page && \method_exists( $this, 'get_item_index' ) ) { - $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $response['id'] ), get_rest_url_by_path( 'seek' ) ); + // The `item` seek parameter is handled before the collection is built and must not leak into navigation links. + $query_params = $request->get_query_params(); + unset( $query_params['item'] ); + + /* + * Advertise the seek endpoint on the Collection when the request offered an `item` argument, + * which is how a controller opts a route in. The seek endpoint receives the collection with + * its filtering arguments (order, per_page, context) but without page, so it resolves the item + * against the same ordering the client is traversing. + */ + $attributes = $request->get_attributes(); + if ( null === $page && isset( $attributes['args']['item'] ) ) { + $collection_id = \add_query_arg( \array_diff_key( $query_params, array( 'page' => '' ) ), $response['id'] ); + + // add_query_arg() does not encode values, so encode the nested collection URL to keep its query string intact. + $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $collection_id ), get_rest_url_by_path( 'seek' ) ); if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { $response['@context'][] = $this->seek_item_context; @@ -81,10 +94,6 @@ public function prepare_collection_response( $response, $request ) { return $response; } - // The `item` seek parameter is handled before the collection is built and must not leak into navigation links. - $query_params = $request->get_query_params(); - unset( $query_params['item'] ); - $response['id'] = \add_query_arg( $query_params, $response['id'] ); $response['first'] = \add_query_arg( 'page', 1, $response['id'] ); $response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] ); @@ -117,6 +126,8 @@ public function prepare_collection_response( $response, $request ) { * * @see https://swicg.github.io/activitypub-api/seekitem * + * @since unreleased + * * @return array The argument definition. */ public function get_seek_item_arg() { @@ -140,6 +151,8 @@ public function get_seek_item_arg() { * * @see https://swicg.github.io/activitypub-api/seekitem * + * @since unreleased + * * @param \WP_REST_Request $request The request object. * @param string $collection_id The plain collection ID (URL without query arguments). * diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index 0c8b98d706..e88fb2b9d1 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -181,7 +181,7 @@ public function test_collection_advertises_seek_item() { \parse_str( (string) \wp_parse_url( $response['seekItem'], PHP_URL_QUERY ), $params ); $this->assertStringContainsString( 'activitypub/1.0/seek', \rawurldecode( $response['seekItem'] ) ); - $this->assertSame( get_rest_url_by_path( 'actors/0/followers' ), $params['collection'] ); + $this->assertSame( get_rest_url_by_path( 'actors/0/followers' ), \rawurldecode( $params['collection'] ) ); $this->assertContains( 'https://purl.archive.org/socialweb/seekitem/1.0', $response['@context'] ); } @@ -278,6 +278,51 @@ public function test_seek_endpoint_collection_without_seek_support() { $this->assertEquals( 404, $response->get_status() ); } + /** + * Seeking into a forced-signature route does not bypass its mandatory signature verification. + * + * FEP-8fcf's /followers/sync forces signatures even when Authorized Fetch is off. A seek defers + * signature verification for the internal dispatch, but must leave forced routes verifying, so an + * unsigned seek into /followers/sync fails verification and collapses to the uniform 404. + * + * @covers ::get_item + */ + public function test_seek_endpoint_does_not_bypass_forced_signature() { + $sync_url = \add_query_arg( 'authority', 'https://example.org', get_rest_url_by_path( 'actors/0/followers/sync' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $sync_url ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * The advertised seekItem preserves the collection's filtering arguments. + * + * @covers \Activitypub\Rest\Collection::prepare_collection_response + */ + public function test_seek_item_preserves_query_arguments() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_query_params( + array( + 'order' => 'asc', + 'per_page' => '5', + ) + ); + + $response = rest_get_server()->dispatch( $request )->get_data(); + + \parse_str( (string) \wp_parse_url( $response['seekItem'], PHP_URL_QUERY ), $params ); + \parse_str( (string) \wp_parse_url( \rawurldecode( $params['collection'] ), PHP_URL_QUERY ), $collection_params ); + + $this->assertSame( 'asc', $collection_params['order'] ); + $this->assertSame( '5', $collection_params['per_page'] ); + $this->assertArrayNotHasKey( 'page', $collection_params ); + } + /** * The seek of an outbox activity respects the visibility rules for unauthenticated requests. * From 182e54de2456849be4f5a25151a6e1165c942e62 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:23:04 +0200 Subject: [PATCH 06/19] Simplify seek plumbing: dedupe the date cursor clause and collapse context handling --- .../rest/class-actors-inbox-controller.php | 11 +------ includes/rest/class-followers-controller.php | 1 + includes/rest/class-following-controller.php | 1 + includes/rest/class-outbox-controller.php | 11 +------ includes/rest/trait-collection.php | 33 ++++++++++++++++--- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 46bbf3f1f4..0dc403a300 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -307,8 +307,6 @@ private function get_query_args( $request ) { * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. */ public function get_item_index( $item, $request ) { - global $wpdb; - $inbox_item = Inbox::get_by_guid( $item ); if ( \is_wp_error( $inbox_item ) ) { return $inbox_item; @@ -325,16 +323,9 @@ public function get_item_index( $item, $request ) { return false; } - $where = $wpdb->prepare( - " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", - $inbox_item->post_date, - $inbox_item->post_date, - $inbox_item->ID - ); - // Count the activities that sort before the item; that count is the item's zero-based index. $preceding = $this->with_posts_where( - $where, + $this->get_preceding_by_date_where( $inbox_item->post_date, $inbox_item->ID ), static function () use ( $args ) { return new \WP_Query( $args ); } diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 93dcb86347..1f6727e9f8 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -274,6 +274,7 @@ public function get_item_index( $item, $request ) { return false; } + // Posts sorting before the item: lower IDs for ascending order, higher IDs for descending. if ( 'asc' === $order ) { $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); } else { diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 3d4a6124bd..3ed45289db 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -180,6 +180,7 @@ public function get_item_index( $item, $request ) { return false; } + // Posts sorting before the item: lower IDs for ascending order, higher IDs for descending. if ( 'asc' === $order ) { $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); } else { diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 4fbcaaa996..ab21c56eee 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -321,8 +321,6 @@ private function get_query_args( $request ) { * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. */ public function get_item_index( $item, $request ) { - global $wpdb; - $outbox_item = Outbox::get_by_guid( $item ); if ( \is_wp_error( $outbox_item ) ) { return $outbox_item; @@ -339,16 +337,9 @@ public function get_item_index( $item, $request ) { return false; } - $where = $wpdb->prepare( - " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", - $outbox_item->post_date, - $outbox_item->post_date, - $outbox_item->ID - ); - // Count the activities that sort before the item; that count is the item's zero-based index. $preceding = $this->with_posts_where( - $where, + $this->get_preceding_by_date_where( $outbox_item->post_date, $outbox_item->ID ), static function () use ( $args ) { return new \WP_Query( $args ); } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index aae60d4aca..6a3d77f4d3 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -77,15 +77,16 @@ public function prepare_collection_response( $response, $request ) { */ $attributes = $request->get_attributes(); if ( null === $page && isset( $attributes['args']['item'] ) ) { - $collection_id = \add_query_arg( \array_diff_key( $query_params, array( 'page' => '' ) ), $response['id'] ); + // A Collection request never carries a page, so the query params already describe the base collection. + $collection_id = \add_query_arg( $query_params, $response['id'] ); // add_query_arg() does not encode values, so encode the nested collection URL to keep its query string intact. $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $collection_id ), get_rest_url_by_path( 'seek' ) ); - if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { - $response['@context'][] = $this->seek_item_context; - } elseif ( \is_string( $response['@context'] ) ) { - $response['@context'] = array( $response['@context'], $this->seek_item_context ); + $context = (array) $response['@context']; + if ( ! \in_array( $this->seek_item_context, $context, true ) ) { + $context[] = $this->seek_item_context; + $response['@context'] = $context; } } @@ -210,6 +211,28 @@ private function with_posts_where( $where, $callback ) { return $result; } + /** + * Build a WHERE clause matching the posts that sort before a cursor in newest-first order. + * + * Mirrors an `orderby` of post_date then ID, both descending, so the count of matching posts is + * the cursor's zero-based index. Shared by the date-ordered collections (outbox, inbox). + * + * @param string $post_date The cursor post's post_date. + * @param int $id The cursor post's ID. + * + * @return string Prepared SQL to append to the WHERE clause. + */ + private function get_preceding_by_date_where( $post_date, $id ) { + global $wpdb; + + return $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $post_date, + $post_date, + $id + ); + } + /** * Get the schema for an ActivityPub Collection. * From a5c408e7766fa37f01857be45a00fd11644b3703 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:31:12 +0200 Subject: [PATCH 07/19] Cover the outbox content-visibility gate in the seek oracle tests --- .../rest/class-test-seek-controller.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index e88fb2b9d1..f69e346305 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -348,6 +348,12 @@ public function test_outbox_collection_seek_respects_visibility() { $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); + // A public activity type that the author marked private: a public type, but hidden by visibility. + $hidden_id = 'https://example.org/outbox/hidden-activity'; + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + // The public activity is seekable anonymously. $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); $request->set_param( 'item', $public_id ); @@ -362,6 +368,50 @@ public function test_outbox_collection_seek_respects_visibility() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); + // A private-visibility activity is not disclosed by the seek oracle to an anonymous request. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $hidden_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + \wp_delete_post( $public_post, true ); } + + /** + * The outbox owner can seek their own private-visibility activity, confirming the 404 above is a + * visibility gate and not a broken lookup. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_owner_can_seek_private_activity() { + $hidden_id = 'https://example.org/outbox/owner-hidden-activity'; + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + self::factory()->post->create( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'guid' => $hidden_id, + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'user', + '_activitypub_activity_type' => 'Create', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + ), + ) + ); + + \wp_set_current_user( $user_id ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $request->set_param( 'item', $hidden_id ); + + $response = rest_get_server()->dispatch( $request ); + + \wp_set_current_user( 0 ); + + $this->assertEquals( 307, $response->get_status() ); + } } From cbfc3488d2ece56ff9168b3dbcfd86d918968ad5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:49:45 +0200 Subject: [PATCH 08/19] Make outbox seek owner-only and surface auth failures instead of masking them as 404 --- includes/rest/class-outbox-controller.php | 13 +- includes/rest/class-seek-controller.php | 7 +- includes/rest/trait-collection.php | 19 ++- .../rest/class-test-seek-controller.php | 113 ++++++++++-------- 4 files changed, 98 insertions(+), 54 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index ab21c56eee..6cdab04e98 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -311,16 +311,25 @@ private function get_query_args( $request ) { } /** - * Get the position of an activity in the outbox, under the collection's own query rules. + * Get the position of an activity in the outbox. + * + * Seeking the outbox is owner-only. The collection mixes public and private activities, so + * rather than rely on the per-item visibility filter, non-owners are refused before any item + * is resolved. That way the seek discloses nothing about a specific activity, public or private. * * @since unreleased * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * - * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found or not permitted. */ public function get_item_index( $item, $request ) { + $owner = $this->verify_owner( $request ); + if ( true !== $owner ) { + return $owner; + } + $outbox_item = Outbox::get_by_guid( $item ); if ( \is_wp_error( $outbox_item ) ) { return $outbox_item; diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 1854405611..701cd084de 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -134,7 +134,12 @@ public function get_item( $request ) { $response = \rest_do_request( $collection_request ); \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); - if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { + /* + * A redirect is the sought page. An authentication or authorization failure is a property of + * the request, not the item, so it is surfaced as is (it discloses no membership). Everything + * else, including the collection's own item-not-found, collapses to a single 404. + */ + if ( \in_array( $response->get_status(), array( 307, 308, 401, 403 ), true ) ) { return $response; } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 6a3d77f4d3..972de42b58 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -167,7 +167,24 @@ public function maybe_seek_item( $request, $collection_id ) { $index = $this->get_item_index( $item, $request ); - if ( \is_wp_error( $index ) || false === $index ) { + if ( \is_wp_error( $index ) ) { + /* + * Surface an authentication or authorization failure as is: it is a property of the + * request, not the item, so it discloses no collection membership. Everything else + * (item absent, or hidden by the collection's visibility rules) collapses to a single + * 404 so the presence of a specific item can never be inferred. + */ + $data = $index->get_error_data(); + $status = \is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; + + if ( \in_array( $status, array( 401, 403 ), true ) ) { + return $index; + } + + $index = false; + } + + if ( false === $index ) { return new \WP_Error( 'activitypub_item_not_found', \__( 'The requested item could not be found in this collection.', 'activitypub' ), diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index f69e346305..4bb85611d2 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -283,7 +283,8 @@ public function test_seek_endpoint_collection_without_seek_support() { * * FEP-8fcf's /followers/sync forces signatures even when Authorized Fetch is off. A seek defers * signature verification for the internal dispatch, but must leave forced routes verifying, so an - * unsigned seek into /followers/sync fails verification and collapses to the uniform 404. + * unsigned seek into /followers/sync still fails verification. That failure is a property of the + * request, not any follower, so it surfaces as 401 and discloses no membership. * * @covers ::get_item */ @@ -296,7 +297,7 @@ public function test_seek_endpoint_does_not_bypass_forced_signature() { $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + $this->assertEquals( 401, $response->get_status() ); } /** @@ -324,13 +325,15 @@ public function test_seek_item_preserves_query_arguments() { } /** - * The seek of an outbox activity respects the visibility rules for unauthenticated requests. + * Seeking the outbox is owner-only: a non-owner gets the same 403 for a public activity, a + * private-visibility activity, and an unknown one, so the seek discloses nothing about the + * outbox regardless of an activity's visibility. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ - public function test_outbox_collection_seek_respects_visibility() { - $public_id = 'https://example.org/outbox/public-activity'; - $private_id = 'https://example.org/outbox/private-activity'; + public function test_outbox_seek_is_owner_only() { + $public_id = 'https://example.org/outbox/public-activity'; + $hidden_id = 'https://example.org/outbox/hidden-activity'; $create = array( 'post_type' => Outbox::POST_TYPE, @@ -344,74 +347,84 @@ public function test_outbox_collection_seek_respects_visibility() { $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); - $follow = $create; - $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; - self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); - - // A public activity type that the author marked private: a public type, but hidden by visibility. - $hidden_id = 'https://example.org/outbox/hidden-activity'; - $hidden = $create; + $hidden = $create; $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); - // The public activity is seekable anonymously. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $public_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 307, $response->get_status() ); - - // The non-public activity type is invisible to anonymous seeks. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $private_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + // A public activity, a private one, and an unknown one all return an identical 403 to a non-owner. + foreach ( array( $public_id, $hidden_id, 'https://example.org/outbox/does-not-exist' ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $item ); - // A private-visibility activity is not disclosed by the seek oracle to an anonymous request. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $hidden_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 403, $response->get_status(), "Non-owner seek of {$item} must be refused with 403." ); + } \wp_delete_post( $public_post, true ); } /** - * The outbox owner can seek their own private-visibility activity, confirming the 404 above is a - * visibility gate and not a broken lookup. + * The outbox owner can seek their own activities, including a private-visibility one, confirming + * the non-owner 403 is an access gate and not a broken lookup. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ - public function test_outbox_owner_can_seek_private_activity() { + public function test_outbox_owner_can_seek_own_activities() { + $public_id = 'https://example.org/outbox/owner-public-activity'; $hidden_id = 'https://example.org/outbox/owner-hidden-activity'; $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - self::factory()->post->create( - array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'publish', - 'post_author' => $user_id, - 'guid' => $hidden_id, - 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), - 'meta_input' => array( - '_activitypub_activity_actor' => 'user', - '_activitypub_activity_type' => 'Create', - 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, - ), - ) + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'user', + '_activitypub_activity_type' => 'Create', + ), ); + self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + \wp_set_current_user( $user_id ); + foreach ( array( $public_id, $hidden_id ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $request->set_param( 'item', $item ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status(), "Owner seek of {$item} must resolve to a page." ); + } + + // The owner still gets a 404 for an activity that is genuinely not in their outbox. $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); - $request->set_param( 'item', $hidden_id ); + $request->set_param( 'item', 'https://example.org/outbox/never-existed' ); $response = rest_get_server()->dispatch( $request ); \wp_set_current_user( 0 ); - $this->assertEquals( 307, $response->get_status() ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * An anonymous seek into the owner-only inbox returns 401, not a misleading 404: the failure is a + * property of the request, not of any item, so surfacing it discloses no membership. + * + * @covers ::get_item + * @covers \Activitypub\Rest\Collection::maybe_seek_item + */ + public function test_inbox_seek_requires_authentication() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/inbox' ); + $request->set_param( 'item', 'https://example.org/activity/1' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); } } From 58e2deebf59213e301d6303e0c7817fe5bd63de5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:00:23 +0200 Subject: [PATCH 09/19] Fix stale seek docblock and avoid re-deriving outbox ownership --- includes/rest/class-outbox-controller.php | 11 +++++++---- includes/rest/trait-collection.php | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 6cdab04e98..acd4c2d71b 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -232,11 +232,13 @@ public function get_items( $request ) { * Shared by get_items() and get_item_index(), so the seek index is computed under the * exact same visibility rules as the collection itself. * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request $request Full details about the request. + * @param bool|null $is_owner Optional. Whether the requester owns the outbox, when the + * caller already knows. Default null, which re-derives it. * * @return array The WP_Query arguments. */ - private function get_query_args( $request ) { + private function get_query_args( $request, $is_owner = null ) { $user_id = $request->get_param( 'user_id' ); /** @@ -277,7 +279,7 @@ private function get_query_args( $request ) { * 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. */ - $is_outbox_owner = true === $this->verify_owner( $request ); + $is_outbox_owner = null === $is_owner ? true === $this->verify_owner( $request ) : $is_owner; if ( ! $is_outbox_owner ) { $args['meta_query'][] = array( @@ -335,7 +337,8 @@ public function get_item_index( $item, $request ) { return $outbox_item; } - $args = $this->get_query_args( $request ); + // Ownership is already established above, so the shared query builder need not re-derive it. + $args = $this->get_query_args( $request, true ); $args['fields'] = 'ids'; $args['posts_per_page'] = 1; unset( $args['paged'] ); diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 972de42b58..edd36bc197 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -147,8 +147,9 @@ public function get_seek_item_arg() { * visibility rules of the collection, and the response is a temporary redirect whose * Location is the id of the CollectionPage containing the item. A temporary redirect is * used because the collections are ordered newest-first, so items drift across pages as - * new items arrive. Unknown, invisible, and unauthorized items all produce the same 404, - * so collection membership is not leaked. + * new items arrive. Unknown and invisible items both produce the same 404, so collection + * membership is not leaked; an authentication or authorization failure is surfaced as is, + * because it describes the request rather than any item and discloses no membership. * * @see https://swicg.github.io/activitypub-api/seekitem * @@ -157,7 +158,7 @@ public function get_seek_item_arg() { * @param \WP_REST_Request $request The request object. * @param string $collection_id The plain collection ID (URL without query arguments). * - * @return \WP_REST_Response|\WP_Error|null Redirect response, 404 error, or null when this is not a seek request. + * @return \WP_REST_Response|\WP_Error|null Redirect response, 401/403/404 error, or null when this is not a seek request. */ public function maybe_seek_item( $request, $collection_id ) { $item = $request->get_param( 'item' ); From bc41d584fb2027c6ece83cab3bbcc7ad4f579565 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:26:09 +0200 Subject: [PATCH 10/19] Note why the inbox seek index needs no owner gate of its own --- includes/rest/class-actors-inbox-controller.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 0dc403a300..548667b568 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,6 +299,9 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * + * The inbox route is owner-only (it requires authentication), so unlike the outbox this method + * needs no seek gate of its own: a non-owner is already refused before it runs. + * * @since unreleased * * @param string $item The ActivityPub activity ID. From e3850df325ba10f221d233462e93b34050158ea5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:33:16 +0200 Subject: [PATCH 11/19] Return 401 for unauthenticated seeks and 404 for authenticated non-owners --- includes/rest/class-outbox-controller.php | 18 ++++-- includes/rest/class-seek-controller.php | 9 +-- includes/rest/trait-collection.php | 11 ++-- .../rest/class-test-seek-controller.php | 55 ++++++++++++++++--- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index acd4c2d71b..1300ea3247 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -318,18 +318,28 @@ private function get_query_args( $request, $is_owner = null ) { * Seeking the outbox is owner-only. The collection mixes public and private activities, so * rather than rely on the per-item visibility filter, non-owners are refused before any item * is resolved. That way the seek discloses nothing about a specific activity, public or private. + * A request without credentials is asked to authenticate (401); an authenticated non-owner is + * refused with the same 404 as a missing item, so it can infer nothing about the outbox. * * @since unreleased * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * - * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found or not permitted. + * @return int|false|\WP_Error Zero-based index of the item, false when not found or a non-owner, or WP_Error when unauthenticated. */ public function get_item_index( $item, $request ) { - $owner = $this->verify_owner( $request ); - if ( true !== $owner ) { - return $owner; + if ( true !== $this->verify_owner( $request ) ) { + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'You need to authenticate to seek this collection.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + return false; } $outbox_item = Outbox::get_by_guid( $item ); diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 701cd084de..c731e69731 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -135,11 +135,12 @@ public function get_item( $request ) { \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); /* - * A redirect is the sought page. An authentication or authorization failure is a property of - * the request, not the item, so it is surfaced as is (it discloses no membership). Everything - * else, including the collection's own item-not-found, collapses to a single 404. + * A redirect is the sought page. A missing-authentication failure (401) is a property of the + * request, not the item, so it is surfaced as is (it discloses no membership) to tell the + * client to authenticate. Everything else — an authenticated-but-not-authorized request, or + * the collection's own item-not-found — collapses to a single 404, per the seekItem spec. */ - if ( \in_array( $response->get_status(), array( 307, 308, 401, 403 ), true ) ) { + if ( \in_array( $response->get_status(), array( 307, 308, 401 ), true ) ) { return $response; } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index edd36bc197..e5e70fa47a 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -170,15 +170,16 @@ public function maybe_seek_item( $request, $collection_id ) { if ( \is_wp_error( $index ) ) { /* - * Surface an authentication or authorization failure as is: it is a property of the - * request, not the item, so it discloses no collection membership. Everything else - * (item absent, or hidden by the collection's visibility rules) collapses to a single - * 404 so the presence of a specific item can never be inferred. + * Surface a missing-authentication failure (401) as is: it is a property of the request, + * not the item, so it discloses no membership while telling the client to authenticate. + * Everything else — an authenticated-but-not-authorized request, an absent item, or one + * hidden by the collection's visibility rules — collapses to a single 404 so the presence + * of a specific item can never be inferred, per the seekItem spec. */ $data = $index->get_error_data(); $status = \is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; - if ( \in_array( $status, array( 401, 403 ), true ) ) { + if ( 401 === $status ) { return $index; } diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index 4bb85611d2..c4c96fc183 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -325,13 +325,13 @@ public function test_seek_item_preserves_query_arguments() { } /** - * Seeking the outbox is owner-only: a non-owner gets the same 403 for a public activity, a - * private-visibility activity, and an unknown one, so the seek discloses nothing about the - * outbox regardless of an activity's visibility. + * Seeking the outbox is owner-only. An unauthenticated request is asked to authenticate (401), + * identically for a public activity, a private one, and an unknown one — so the 401 discloses + * nothing about the outbox. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ - public function test_outbox_seek_is_owner_only() { + public function test_outbox_seek_unauthenticated_returns_401() { $public_id = 'https://example.org/outbox/public-activity'; $hidden_id = 'https://example.org/outbox/hidden-activity'; @@ -351,21 +351,62 @@ public function test_outbox_seek_is_owner_only() { $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); - // A public activity, a private one, and an unknown one all return an identical 403 to a non-owner. foreach ( array( $public_id, $hidden_id, 'https://example.org/outbox/does-not-exist' ) as $item ) { $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); $request->set_param( 'item', $item ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 403, $response->get_status(), "Non-owner seek of {$item} must be refused with 403." ); + $this->assertEquals( 401, $response->get_status(), "Unauthenticated seek of {$item} must ask to authenticate with 401." ); } \wp_delete_post( $public_post, true ); } + /** + * An authenticated non-owner is refused with the uniform 404 — the same status as a missing + * item — for a public activity, a private one, and an unknown one, so it can infer nothing about + * another actor's outbox. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_seek_authenticated_non_owner_returns_404() { + $public_id = 'https://example.org/outbox/other-public-activity'; + $hidden_id = 'https://example.org/outbox/other-hidden-activity'; + + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'blog', + '_activitypub_activity_type' => 'Create', + ), + ); + + $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + + // A regular author cannot act as the blog actor, so seeking the blog outbox makes them a non-owner. + \wp_set_current_user( self::factory()->user->create( array( 'role' => 'author' ) ) ); + + foreach ( array( $public_id, $hidden_id, 'https://example.org/outbox/does-not-exist' ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $item ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status(), "Authenticated non-owner seek of {$item} must return the uniform 404." ); + } + + \wp_set_current_user( 0 ); + \wp_delete_post( $public_post, true ); + } + /** * The outbox owner can seek their own activities, including a private-visibility one, confirming - * the non-owner 403 is an access gate and not a broken lookup. + * the non-owner refusal is an access gate and not a broken lookup. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ From bfb1b0d092522632bb91b64d0f2a390af833f65f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 20:14:22 +0200 Subject: [PATCH 12/19] Flatten the outbox seek gate and document the inbox 403-to-404 collapse --- .../rest/class-actors-inbox-controller.php | 6 ++++-- includes/rest/class-outbox-controller.php | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 548667b568..ed6304faeb 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,8 +299,10 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * - * The inbox route is owner-only (it requires authentication), so unlike the outbox this method - * needs no seek gate of its own: a non-owner is already refused before it runs. + * The inbox route requires authentication, so unlike the outbox this method needs no seek gate + * of its own: a non-owner is already refused before it runs — unauthenticated with 401, and an + * authenticated non-owner with a 403 that Seek_Controller collapses to the uniform 404 so the + * seek discloses no membership. Any new seek surface must preserve that 403 → 404 collapse. * * @since unreleased * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 1300ea3247..c45637ba88 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -329,16 +329,17 @@ private function get_query_args( $request, $is_owner = null ) { * @return int|false|\WP_Error Zero-based index of the item, false when not found or a non-owner, or WP_Error when unauthenticated. */ public function get_item_index( $item, $request ) { - if ( true !== $this->verify_owner( $request ) ) { - if ( ! \is_user_logged_in() ) { - return new \WP_Error( - 'activitypub_unauthorized', - \__( 'You need to authenticate to seek this collection.', 'activitypub' ), - array( 'status' => 401 ) - ); - } + // Ask an unauthenticated request to authenticate; the seek is owner-only. + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'You need to authenticate to seek this collection.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + if ( true !== $this->verify_owner( $request ) ) { return false; } From 3d99d50d824f31713a79b47798c3947779c0f8c0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Jul 2026 11:59:29 +0200 Subject: [PATCH 13/19] Harden the seek defer filter against override and fix the response-code docblocks --- includes/rest/class-seek-controller.php | 17 +++++++------ includes/rest/trait-collection.php | 9 +++---- .../rest/class-test-seek-controller.php | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index c731e69731..1c0f34a9b5 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -94,12 +94,13 @@ public function get_item_permissions_check( $request ) { * Seek an item in a collection. * * Dispatches an internal request to the collection endpoint with the `item` parameter and - * passes its redirect through. Unknown collections, foreign URLs, items that are not part - * of the collection, and unauthorized requests all produce the same 404, so collection - * membership is not leaked. + * passes its redirect through. A missing-authentication failure surfaces as 401 to tell the + * client to authenticate. Unknown collections, foreign URLs, items that are not part of the + * collection, and authenticated-but-not-authorized requests all produce the same 404, so + * collection membership is not leaked. * * @param \WP_REST_Request $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Redirect response on success, or WP_Error object on failure. + * @return \WP_REST_Response|\WP_Error Redirect on success, or 401/404 WP_Error on failure. */ public function get_item( $request ) { $not_found = new \WP_Error( @@ -128,11 +129,13 @@ public function get_item( $request ) { * non-seekable collection, so no membership is leaked. */ $defer = static function ( $deferred, $inner_request, $force_signature ) { - return $force_signature ? $deferred : true; + // Decide deterministically: defer for the dispatch, except on forced-signature routes. + return ! $force_signature; }; - \add_filter( 'activitypub_defer_signature_verification', $defer, 10, 3 ); + // Latest priority, so a global defer filter (e.g. a local-dev __return_true) cannot reopen a forced route. + \add_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX, 3 ); $response = \rest_do_request( $collection_request ); - \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); + \remove_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX ); /* * A redirect is the sought page. A missing-authentication failure (401) is a property of the diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index e5e70fa47a..9e49afae46 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -147,9 +147,10 @@ public function get_seek_item_arg() { * visibility rules of the collection, and the response is a temporary redirect whose * Location is the id of the CollectionPage containing the item. A temporary redirect is * used because the collections are ordered newest-first, so items drift across pages as - * new items arrive. Unknown and invisible items both produce the same 404, so collection - * membership is not leaked; an authentication or authorization failure is surfaced as is, - * because it describes the request rather than any item and discloses no membership. + * new items arrive. A missing-authentication failure is surfaced as 401, because it describes + * the request rather than any item and discloses no membership. Everything else — unknown or + * invisible items, and authenticated-but-not-authorized requests — collapses to the same 404, + * so collection membership is not leaked. * * @see https://swicg.github.io/activitypub-api/seekitem * @@ -158,7 +159,7 @@ public function get_seek_item_arg() { * @param \WP_REST_Request $request The request object. * @param string $collection_id The plain collection ID (URL without query arguments). * - * @return \WP_REST_Response|\WP_Error|null Redirect response, 401/403/404 error, or null when this is not a seek request. + * @return \WP_REST_Response|\WP_Error|null Redirect response, 401/404 error, or null when this is not a seek request. */ public function maybe_seek_item( $request, $collection_id ) { $item = $request->get_param( 'item' ); diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index c4c96fc183..fa4917bec2 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -300,6 +300,30 @@ public function test_seek_endpoint_does_not_bypass_forced_signature() { $this->assertEquals( 401, $response->get_status() ); } + /** + * A global signature-defer filter cannot reopen a forced-signature route through a seek. + * + * The seek registers its own defer decision at the latest priority, so a broad defer filter (as a + * local-dev setup might install) still leaves /followers/sync verifying and the unsigned seek fails. + * + * @covers ::get_item + */ + public function test_global_defer_filter_does_not_reopen_forced_signature() { + \add_filter( 'activitypub_defer_signature_verification', '__return_true', 20 ); + + $sync_url = \add_query_arg( 'authority', 'https://example.org', get_rest_url_by_path( 'actors/0/followers/sync' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $sync_url ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true', 20 ); + + $this->assertEquals( 401, $response->get_status() ); + } + /** * The advertised seekItem preserves the collection's filtering arguments. * From 06c80d3741311bf65d65a71f469cb3f0dfb818b9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 11:35:17 +0200 Subject: [PATCH 14/19] Harden the seek endpoint against a HEAD membership oracle - Narrow the HEAD signature short-circuit so a seek (item present) cannot ride the bypass. Under Authorized Fetch an anonymous HEAD seek is now challenged (401) instead of answering with a 307 that leaks a per-actor membership and ordinal position. Adds a regression test. - Restrict the seek dispatch to routes that declare an item argument, so /seek cannot internally invoke arbitrary ActivityPub GET routes with signature verification deferred. A non-seekable route (e.g. /followers/sync) is now rejected with 404 before any dispatch. - Advertise seekItem on the public outbox only to the authenticated owner, since the outbox seek is owner-only. - Document that the date-ordered index assumes the collection's default ordering; a query-reordering filter invalidates it. - Replace vacuous page assertions (per_page=10 contains the text page=1) with a query-parsing helper. --- includes/rest/class-outbox-controller.php | 11 ++++ includes/rest/class-seek-controller.php | 10 +++ includes/rest/trait-collection.php | 22 ++++++- includes/rest/trait-verification.php | 8 ++- .../rest/class-test-seek-controller.php | 66 +++++++++++++++---- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index c45637ba88..b850f00e2e 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -371,6 +371,17 @@ static function () use ( $args ) { return (int) $preceding->found_posts; } + /** + * The outbox seek is owner-only, so only advertise seekItem to the authenticated owner. + * + * @param \WP_REST_Request $request The collection request. + * + * @return bool + */ + protected function can_advertise_seek( $request ) { + return \is_user_logged_in() && true === $this->verify_owner( $request ); + } + /** * Prepares the item for the REST response. * diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 1c0f34a9b5..42aae8fbcb 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -116,6 +116,16 @@ public function get_item( $request ) { return $not_found; } + /* + * Only dispatch to routes that actually implement seek (they declare an `item` argument, the + * same opt-in prepare_collection_response() checks). Without this, the signature-deferred + * dispatch below could be pointed at any ActivityPub GET route rather than a real collection. + */ + $handler = \rest_get_server()->match_request_to_handler( $collection_request ); + if ( \is_wp_error( $handler ) || ! isset( $handler[1]['args']['item'] ) ) { + return $not_found; + } + $collection_request->set_param( 'item', $request->get_param( 'item' ) ); // Carry the original headers over, so a Bearer token reaches the collection's own permission check. $collection_request->set_headers( $request->get_headers() ); diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 9e49afae46..d64427663a 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -76,7 +76,7 @@ public function prepare_collection_response( $response, $request ) { * against the same ordering the client is traversing. */ $attributes = $request->get_attributes(); - if ( null === $page && isset( $attributes['args']['item'] ) ) { + if ( null === $page && isset( $attributes['args']['item'] ) && $this->can_advertise_seek( $request ) ) { // A Collection request never carries a page, so the query params already describe the base collection. $collection_id = \add_query_arg( $query_params, $response['id'] ); @@ -208,6 +208,21 @@ public function maybe_seek_item( $request, $collection_id ) { return $response; } + /** + * Whether the seekItem endpoint should be advertised to this request. + * + * Defaults to true for any route that opts in via an `item` argument. Collections whose seek is + * restricted to a specific audience (e.g. the owner-only outbox) override this so the capability + * is not advertised to clients that would only ever receive a 401 or 404 from it. + * + * @param \WP_REST_Request $request The collection request. + * + * @return bool True to advertise seekItem, false to omit it. + */ + protected function can_advertise_seek( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return true; + } + /** * Run a callback with an additional WHERE clause appended to WP_Query's SQL. * @@ -237,6 +252,11 @@ private function with_posts_where( $where, $callback ) { * Mirrors an `orderby` of post_date then ID, both descending, so the count of matching posts is * the cursor's zero-based index. Shared by the date-ordered collections (outbox, inbox). * + * This assumes the collection's default newest-first date ordering. A site that reorders the + * outbox/inbox through the `activitypub_rest_outbox_query` / `activitypub_rest_inbox_query` + * filter makes this index disagree with the paginated order, so seek only resolves correctly + * under the default ordering. + * * @param string $post_date The cursor post's post_date. * @param int $id The cursor post's ID. * diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php index 7ddc0f936c..54e339c87a 100644 --- a/includes/rest/trait-verification.php +++ b/includes/rest/trait-verification.php @@ -44,7 +44,13 @@ trait Verification { * @return bool|\WP_Error True if authorized, WP_Error otherwise. */ public function verify_signature( $request, $force_signature = false ) { - if ( 'HEAD' === $request->get_method() && ! $force_signature ) { + /* + * The HEAD short-circuit exists so caches and link-checkers can probe public endpoints + * without a signature. A seek request carries an `item` and its 307/Location response leaks + * a per-actor membership/position, so it must NOT ride the bypass: keep it subject to the + * normal Authorized-Fetch signature check. + */ + if ( 'HEAD' === $request->get_method() && ! $force_signature && null === $request->get_param( 'item' ) ) { return true; } diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index fa4917bec2..8bc2606909 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -28,6 +28,22 @@ class Test_Seek_Controller extends \Activitypub\Tests\Test_REST_Controller_Testc */ public static $follower_ids = array(); + /** + * Assert that a Location header points at a specific collection page. + * + * Parses the `page` query argument so the check cannot be satisfied by the `per_page` + * substring — `per_page=10` contains the literal text `page=1`. + * + * @param int $expected The expected page number. + * @param string $location The Location header value. + */ + private function assert_location_page( $expected, $location ) { + $params = array(); + \wp_parse_str( (string) \wp_parse_url( $location, PHP_URL_QUERY ), $params ); + $this->assertArrayHasKey( 'page', $params, 'Location must carry a page argument.' ); + $this->assertSame( (string) $expected, (string) $params['page'] ); + } + /** * Set up before class. */ @@ -92,7 +108,7 @@ public function test_followers_collection_item_param_redirects_to_page() { $this->assertEquals( 307, $response->get_status() ); $location = $response->get_headers()['Location']; - $this->assertStringContainsString( 'page=2', $location ); + $this->assert_location_page( 2, $location ); $this->assertStringContainsString( 'per_page=10', $location ); $this->assertStringNotContainsString( 'item=', $location ); } @@ -109,7 +125,7 @@ public function test_followers_collection_seek_page_boundaries() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 307, $response->get_status() ); - $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + $this->assert_location_page( 1, $response->get_headers()['Location'] ); $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); $request->set_param( 'item', 'https://example.org/actor/1' ); @@ -117,7 +133,7 @@ public function test_followers_collection_seek_page_boundaries() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 307, $response->get_status() ); - $this->assertStringContainsString( 'page=3', $response->get_headers()['Location'] ); + $this->assert_location_page( 3, $response->get_headers()['Location'] ); } /** @@ -133,7 +149,7 @@ public function test_followers_collection_seek_respects_order() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 307, $response->get_status() ); - $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + $this->assert_location_page( 1, $response->get_headers()['Location'] ); } /** @@ -215,7 +231,8 @@ public function test_get_item() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 307, $response->get_status() ); - $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + // No per_page is set, so the collection default applies and actor/13 (index 12) is on page one. + $this->assert_location_page( 1, $response->get_headers()['Location'] ); } /** @@ -281,10 +298,10 @@ public function test_seek_endpoint_collection_without_seek_support() { /** * Seeking into a forced-signature route does not bypass its mandatory signature verification. * - * FEP-8fcf's /followers/sync forces signatures even when Authorized Fetch is off. A seek defers - * signature verification for the internal dispatch, but must leave forced routes verifying, so an - * unsigned seek into /followers/sync still fails verification. That failure is a property of the - * request, not any follower, so it surfaces as 401 and discloses no membership. + * FEP-8fcf's /followers/sync is not a seekable collection: it does not declare an `item` + * argument, so the seek endpoint refuses to dispatch to it and returns the uniform 404. The + * forced-signature route is therefore never reached through a seek at all — a stronger guarantee + * than relying on its own 401 — and no membership is disclosed. * * @covers ::get_item */ @@ -297,14 +314,15 @@ public function test_seek_endpoint_does_not_bypass_forced_signature() { $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 401, $response->get_status() ); + $this->assertEquals( 404, $response->get_status() ); } /** * A global signature-defer filter cannot reopen a forced-signature route through a seek. * - * The seek registers its own defer decision at the latest priority, so a broad defer filter (as a - * local-dev setup might install) still leaves /followers/sync verifying and the unsigned seek fails. + * Even with a broad defer filter installed (as a local-dev setup might), /followers/sync is not + * a seekable collection, so the seek endpoint rejects it with 404 before any dispatch — the + * forced route is never reached and the defer filter never runs against it. * * @covers ::get_item */ @@ -321,7 +339,29 @@ public function test_global_defer_filter_does_not_reopen_forced_signature() { \remove_filter( 'activitypub_defer_signature_verification', '__return_true', 20 ); - $this->assertEquals( 401, $response->get_status() ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * A HEAD seek must not bypass Authorized-Fetch signature verification. + * + * The HEAD short-circuit in verify_signature() lets caches probe public endpoints unsigned, but a + * seek carries an `item` whose 307/Location leaks a per-actor membership and position. Under + * Authorized Fetch an anonymous HEAD seek must be challenged (401), never answered with a redirect. + * + * @covers \Activitypub\Rest\Followers_Controller::verify_signature + */ + public function test_head_seek_does_not_bypass_authorized_fetch() { + \update_option( 'activitypub_authorized_fetch', '1' ); + + $request = new \WP_REST_Request( 'HEAD', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + \delete_option( 'activitypub_authorized_fetch' ); + + $this->assertEquals( 401, $response->get_status(), 'A HEAD seek under Authorized Fetch must be challenged, not redirected.' ); } /** From a439138594442c0e04d675a4329529c22536e6d0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 11:50:44 +0200 Subject: [PATCH 15/19] fix a fatal in the seek route check --- includes/rest/class-seek-controller.php | 39 +++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 42aae8fbcb..e8db592674 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -90,6 +90,42 @@ public function get_item_permissions_check( $request ) { return $this->verify_signature( $request ); } + /** + * Whether a route implements the seek extension. + * + * A route opts in by declaring an `item` argument on its readable handler, the same signal + * prepare_collection_response() uses to advertise `seekItem`. Route patterns are matched the way + * WP_REST_Server::match_request_to_handler() matches them, which cannot be called directly + * because it is protected. The seek route itself is excluded: it declares an `item` argument of + * its own, and dispatching a seek to a seek would only burn a request. + * + * @since unreleased + * + * @param string $route The route of the request about to be dispatched. + * + * @return bool True if the route declares a seek `item` argument. + */ + private function is_seekable_route( $route ) { + if ( '/' . $this->namespace . '/' . $this->rest_base === $route ) { + return false; + } + + foreach ( \rest_get_server()->get_routes( $this->namespace ) as $pattern => $handlers ) { + if ( ! \preg_match( '@^' . $pattern . '$@i', $route ) ) { + continue; + } + + foreach ( $handlers as $handler ) { + // Only a readable handler can serve a seek; the same route may also register writable ones. + if ( ! empty( $handler['methods']['GET'] ) && isset( $handler['args']['item'] ) ) { + return true; + } + } + } + + return false; + } + /** * Seek an item in a collection. * @@ -121,8 +157,7 @@ public function get_item( $request ) { * same opt-in prepare_collection_response() checks). Without this, the signature-deferred * dispatch below could be pointed at any ActivityPub GET route rather than a real collection. */ - $handler = \rest_get_server()->match_request_to_handler( $collection_request ); - if ( \is_wp_error( $handler ) || ! isset( $handler[1]['args']['item'] ) ) { + if ( ! $this->is_seekable_route( $collection_request->get_route() ) ) { return $not_found; } From 5df43143192f0bd64a3e7848d73d402e0e6ea28b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 11:50:52 +0200 Subject: [PATCH 16/19] ignore the security-scan folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dd3e0d7d9e..bba579f356 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /build/**/*.map /coverage/ /node_modules/ +/security-scan/ /vendor/ _site .idea/ From e47eb72979a3ef9ca5393c8bdd39e337ee9bf710 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 12:12:49 +0200 Subject: [PATCH 17/19] Silence the intentional unused-parameter warning on can_advertise_seek The default seek-advertisement hook ignores its request; overrides (the owner-only outbox) use it. Keep the signature so both PHPCS parameter sniffs pass. --- includes/rest/trait-collection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index d64427663a..69259ce2df 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -219,7 +219,7 @@ public function maybe_seek_item( $request, $collection_id ) { * * @return bool True to advertise seekItem, false to omit it. */ - protected function can_advertise_seek( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + protected function can_advertise_seek( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable return true; } From 38acb63fa56368fb9eb92fd67753113fe803d724 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jul 2026 07:43:16 +0200 Subject: [PATCH 18/19] stop seek from dispatching to itself --- includes/rest/class-seek-controller.php | 44 +++++++++++++++---- .../rest/class-test-seek-controller.php | 27 ++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index e8db592674..d4ff77715e 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -38,6 +38,15 @@ class Seek_Controller extends \WP_REST_Controller { */ protected $rest_base = 'seek'; + /** + * Whether a seek dispatch is currently in flight. + * + * @since unreleased + * + * @var bool + */ + private static $is_dispatching = false; + /** * Register routes. */ @@ -96,8 +105,14 @@ public function get_item_permissions_check( $request ) { * A route opts in by declaring an `item` argument on its readable handler, the same signal * prepare_collection_response() uses to advertise `seekItem`. Route patterns are matched the way * WP_REST_Server::match_request_to_handler() matches them, which cannot be called directly - * because it is protected. The seek route itself is excluded: it declares an `item` argument of - * its own, and dispatching a seek to a seek would only burn a request. + * because it is protected: the dispatch lands on the first registered pattern that matches, and + * on that pattern's first handler serving GET, so only that one handler decides the answer. A + * later pattern that happens to match too says nothing about where the dispatch actually goes. + * + * The seek route itself is excluded — it declares an `item` argument of its own, and a seek + * dispatched to a seek would recurse — and the exclusion compares the registered pattern rather + * than the requested route, because patterns match case-insensitively: a route of `/…/Seek` + * resolves back to this endpoint and would slip past a comparison against the requested route. * * @since unreleased * @@ -106,10 +121,6 @@ public function get_item_permissions_check( $request ) { * @return bool True if the route declares a seek `item` argument. */ private function is_seekable_route( $route ) { - if ( '/' . $this->namespace . '/' . $this->rest_base === $route ) { - return false; - } - foreach ( \rest_get_server()->get_routes( $this->namespace ) as $pattern => $handlers ) { if ( ! \preg_match( '@^' . $pattern . '$@i', $route ) ) { continue; @@ -117,9 +128,16 @@ private function is_seekable_route( $route ) { foreach ( $handlers as $handler ) { // Only a readable handler can serve a seek; the same route may also register writable ones. - if ( ! empty( $handler['methods']['GET'] ) && isset( $handler['args']['item'] ) ) { - return true; + if ( empty( $handler['methods']['GET'] ) ) { + continue; + } + + // This is the handler the dispatch resolves to, so the seek route is where it stops. + if ( '/' . $this->namespace . '/' . $this->rest_base === $pattern ) { + return false; } + + return isset( $handler['args']['item'] ); } } @@ -145,6 +163,11 @@ public function get_item( $request ) { array( 'status' => 404 ) ); + // A collection never dispatches a seek, so a seek reached from inside a seek can only be recursion. + if ( self::$is_dispatching ) { + return $not_found; + } + // Resolves only URLs served by this site's REST API, so no remote request is ever made. $collection_request = \WP_REST_Request::from_url( $request->get_param( 'collection' ) ); @@ -179,7 +202,10 @@ public function get_item( $request ) { }; // Latest priority, so a global defer filter (e.g. a local-dev __return_true) cannot reopen a forced route. \add_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX, 3 ); - $response = \rest_do_request( $collection_request ); + // In flight for the dispatch below, so any seek reached from within it is refused instead of recursing. + self::$is_dispatching = true; + $response = \rest_do_request( $collection_request ); + self::$is_dispatching = false; \remove_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX ); /* diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index 8bc2606909..f023d2edc4 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -280,6 +280,33 @@ public function test_seek_endpoint_rejects_unknown_collections() { $this->assertEquals( 404, $response->get_status() ); } + /** + * A seek pointed at the seek endpoint itself produces a 404, whatever the letter case. + * + * REST routes are matched case-insensitively, so a collection of `…/Seek` resolves back to this + * endpoint. Dispatching it would let a single request drive an arbitrarily deep chain of nested + * seeks, each one re-running the route matching for the whole site. + * + * @covers ::get_item + */ + public function test_seek_endpoint_rejects_seeking_itself() { + foreach ( array( 'seek', 'Seek', 'SEEK' ) as $variant ) { + $nested = \add_query_arg( + 'collection', + \rawurlencode( get_rest_url_by_path( 'actors/0/followers' ) ), + get_rest_url_by_path( $variant ) + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $nested ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status(), "A seek pointed at /{$variant} must not dispatch to the seek endpoint." ); + } + } + /** * A collection without seek support produces a 404 instead of the collection body. * From b4f51ef4ec2d4af7d411b69dba31f1547148cde2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jul 2026 07:43:28 +0200 Subject: [PATCH 19/19] limit the delete signature exception to inboxes --- ...mit-delete-verification-exception-to-inbox | 4 ++ includes/handler/class-delete.php | 13 ++++ .../includes/handler/class-test-delete.php | 39 ++++++++++++ .../rest/class-test-trait-verification.php | 62 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 .github/changelog/limit-delete-verification-exception-to-inbox diff --git a/.github/changelog/limit-delete-verification-exception-to-inbox b/.github/changelog/limit-delete-verification-exception-to-inbox new file mode 100644 index 0000000000..999a3daa13 --- /dev/null +++ b/.github/changelog/limit-delete-verification-exception-to-inbox @@ -0,0 +1,4 @@ +Significance: patch +Type: security + +Ensure that only account deletions delivered to your site can skip the usual sender verification. diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 78d5398803..442e199a0a 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -305,6 +305,7 @@ public static function skip_inbox_storage( $skip, $data ) { * arrives. * * @since 8.2.0 The `$force_signature` parameter is now respected. + * @since unreleased Limited to POST deliveries to an inbox route. * * @param bool $defer Whether to defer signature verification. * @param \WP_REST_Request $request The request object. @@ -317,6 +318,18 @@ public static function defer_signature_verification( $defer, $request, $force_si return $defer; } + // Deliveries are POSTs; on any other method the body is not an activity we accept, so it must not waive verification. + if ( 'POST' !== $request->get_method() ) { + return $defer; + } + + $route = $request->get_route(); + + // The carve-out is for inbox deliveries only: both the shared inbox and the per-actor inboxes end in `/inbox`. + if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) || ! \str_ends_with( $route, '/inbox' ) ) { + return $defer; + } + $json = $request->get_json_params(); if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) { diff --git a/tests/phpunit/tests/includes/handler/class-test-delete.php b/tests/phpunit/tests/includes/handler/class-test-delete.php index 3b3dcadf4f..a7a7ef6bea 100644 --- a/tests/phpunit/tests/includes/handler/class-test-delete.php +++ b/tests/phpunit/tests/includes/handler/class-test-delete.php @@ -662,6 +662,45 @@ public function test_defer_signature_verification_respects_force_signature() { $this->assertFalse( Delete::defer_signature_verification( false, $request, true ) ); } + /** + * Data provider for the inbox-only scoping of the Delete carve-out. + * + * @return array[] Test cases: [ method, route, expected ]. + */ + public function data_defer_signature_verification_scope() { + return array( + 'shared inbox delivery' => array( 'POST', '/activitypub/1.0/inbox', true ), + 'actor inbox delivery' => array( 'POST', '/activitypub/1.0/actors/1/inbox', true ), + 'legacy user inbox' => array( 'POST', '/activitypub/1.0/users/1/inbox', true ), + 'read of a collection' => array( 'GET', '/activitypub/1.0/actors/1/followers', false ), + 'seek in a collection' => array( 'GET', '/activitypub/1.0/seek', false ), + 'probe of a collection' => array( 'HEAD', '/activitypub/1.0/actors/1/followers', false ), + 'non-inbox delivery' => array( 'POST', '/activitypub/1.0/actors/1/outbox', false ), + 'inbox outside namespace' => array( 'POST', '/wp/v2/inbox', false ), + 'inbox stream' => array( 'POST', '/activitypub/1.0/actors/1/inbox/stream', false ), + ); + } + + /** + * The `type` in the body is caller-controlled, so the carve-out must only apply + * to POST deliveries to an inbox. Everywhere else — reads, seeks, HEAD probes, + * other routes — signature verification has to stay in place. + * + * @dataProvider data_defer_signature_verification_scope + * @covers ::defer_signature_verification + * + * @param string $method The HTTP method of the request. + * @param string $route The route of the request. + * @param bool $expected Whether signature verification should be deferred. + */ + public function test_defer_signature_verification_scope( $method, $route, $expected ) { + $request = new \WP_REST_Request( $method, $route ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( array( 'type' => 'Delete' ) ) ); + + $this->assertSame( $expected, Delete::defer_signature_verification( false, $request, false ) ); + } + /** * Non-Delete activities pass through the filter unchanged regardless * of force_signature, preserving whatever the incoming $defer value was. diff --git a/tests/phpunit/tests/includes/rest/class-test-trait-verification.php b/tests/phpunit/tests/includes/rest/class-test-trait-verification.php index 1e8c8a9ed7..c4e391635f 100644 --- a/tests/phpunit/tests/includes/rest/class-test-trait-verification.php +++ b/tests/phpunit/tests/includes/rest/class-test-trait-verification.php @@ -139,6 +139,68 @@ public function test_verify_signature_defer_filter() { $this->assertTrue( $this->instance->verify_signature( $request ) ); } + /** + * Test a `Delete` body cannot waive Authorized Fetch on a read request. + * + * The Delete carve-out is meant for inbox deliveries whose signing key may already + * be gone. Its trigger is the caller-controlled `type` in the body, so on a read it + * would hand anonymous callers the gated collections and the seek oracle. + * + * @covers ::verify_signature + */ + public function test_verify_signature_delete_body_does_not_defer_read() { + \update_option( 'activitypub_authorized_fetch', '1' ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/actors/1/followers' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( array( 'type' => 'Delete' ) ) ); + $request->set_param( 'item', 'https://remote.example/users/alice' ); + + $result = $this->instance->verify_signature( $request ); + + \delete_option( 'activitypub_authorized_fetch' ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); + $this->assertEquals( 401, $result->get_error_data()['status'] ); + } + + /** + * Test a `Delete` body cannot put a seek request back on the HEAD bypass. + * + * @covers ::verify_signature + */ + public function test_verify_signature_delete_body_does_not_defer_head_seek() { + \delete_option( 'activitypub_authorized_fetch' ); + + $request = new \WP_REST_Request( 'HEAD', '/activitypub/1.0/actors/1/followers' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( array( 'type' => 'Delete' ) ) ); + $request->set_param( 'item', 'https://remote.example/users/alice' ); + + $result = $this->instance->verify_signature( $request ); + + $this->assertWPError( $result ); + $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() ); + $this->assertEquals( 401, $result->get_error_data()['status'] ); + } + + /** + * Test a `Delete` delivered to the inbox still skips signature verification. + * + * A remote actor's deletion arrives after its keys are gone, so Mastodon's actor + * deletion only federates as long as this path stays deferred. + * + * @covers ::verify_signature + */ + public function test_verify_signature_delete_delivery_to_inbox_defers() { + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/actors/1/inbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( array( 'type' => 'Delete' ) ) ); + + $this->assertTrue( $this->instance->verify_signature( $request ) ); + } + /** * Test GET request uses read scope. *