From ba7468301b8e4f59c6de4a486d3cbb102a194ec9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Oct 2026 17:32:32 +0100 Subject: [PATCH] only deliver locally to inbox routes --- .github/changelog/fix-local-inbox-route | 4 ++ includes/class-dispatcher.php | 9 +++ .../tests/includes/class-test-dispatcher.php | 57 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .github/changelog/fix-local-inbox-route diff --git a/.github/changelog/fix-local-inbox-route b/.github/changelog/fix-local-inbox-route new file mode 100644 index 0000000000..37e2a89825 --- /dev/null +++ b/.github/changelog/fix-local-inbox-route @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activities delivered to accounts on the same site now only reach ActivityPub inboxes. diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index 0530552f04..7882ad8bc7 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -334,6 +334,15 @@ private static function send_to_local_inbox( $inbox_url, $json ) { $path = \wp_parse_url( $inbox_url, PHP_URL_PATH ) ?? ''; $rest_route = \preg_replace( '#^/' . \preg_quote( \rest_get_url_prefix(), '#' ) . '#', '', $path ); + /* + * The inbox URL comes from a remote actor document, and signature verification is deferred below, + * so only the shared inbox and an actor inbox may be reached this way. + */ + $inbox_route = \sprintf( '#^/%s/(?:(?:users|actors)/-?\d+/)?inbox$#', \preg_quote( ACTIVITYPUB_REST_NAMESPACE, '#' ) ); + if ( ! \preg_match( $inbox_route, $rest_route ) ) { + return new \WP_Error( 'activitypub_invalid_inbox', 'The local inbox is not an ActivityPub inbox.', array( 'status' => 400 ) ); + } + // Create a REST request. $request = new \WP_REST_Request( 'POST', $rest_route ); $request->set_header( 'Content-Type', 'application/activity+json' ); diff --git a/tests/phpunit/tests/includes/class-test-dispatcher.php b/tests/phpunit/tests/includes/class-test-dispatcher.php index fb03e1f7cd..8233f7f483 100644 --- a/tests/phpunit/tests/includes/class-test-dispatcher.php +++ b/tests/phpunit/tests/includes/class-test-dispatcher.php @@ -825,6 +825,63 @@ public function test_local_inboxes_skip_http() { \remove_filter( 'pre_http_request', $http_callback ); } + /** + * Test that a local delivery only reaches the shared inbox and actor inboxes. + * + * The inbox URL comes from a remote actor document, and signature verification is deferred + * for local deliveries, so any other route on this site must not be dispatched. + * + * @covers ::send_to_inboxes + * @covers ::send_to_local_inbox + */ + public function test_local_delivery_only_reaches_inbox_routes() { + // Local delivery reads the route from the URL path, which plain permalinks do not have. + $this->set_permalink_structure( '/%postname%/' ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $outbox_item = $this->get_latest_outbox_item( \add_query_arg( 'p', $post_id, \home_url( '/' ) ) ); + + $inboxes = array( + \rest_url( \sprintf( '/%s/inbox', ACTIVITYPUB_REST_NAMESPACE ) ), + \rest_url( \sprintf( '/%s/actors/%d/inbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ), + ); + $others = array( + \rest_url( \sprintf( '/%s/outbox', ACTIVITYPUB_REST_NAMESPACE ) ), + \rest_url( \sprintf( '/%s/actors/%d/collections/inbox', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ), + \rest_url( '/wp/v2/posts' ), + ); + + $dispatched = array(); + $record = function ( $result, $server, $request ) use ( &$dispatched ) { + $dispatched[] = $request->get_route(); + + return $result; + }; + \add_filter( 'rest_pre_dispatch', $record, 10, 3 ); + + $results = array(); + $collect = function ( $result, $inbox ) use ( &$results ) { + $results[ $inbox ] = $result; + }; + \add_action( 'activitypub_sent_to_inbox', $collect, 10, 2 ); + + $send_to_inboxes = new \ReflectionMethod( Dispatcher::class, 'send_to_inboxes' ); + if ( \PHP_VERSION_ID < 80100 ) { + $send_to_inboxes->setAccessible( true ); + } + $send_to_inboxes->invoke( null, \array_merge( $inboxes, $others ), $outbox_item->ID ); + + \remove_filter( 'rest_pre_dispatch', $record ); + \remove_action( 'activitypub_sent_to_inbox', $collect ); + + $this->assertCount( 2, $dispatched, 'Only the two inboxes should be dispatched.' ); + + foreach ( $others as $other ) { + $this->assertWPError( $results[ $other ], $other . ' must not be reached.' ); + $this->assertSame( 'activitypub_invalid_inbox', $results[ $other ]->get_error_code() ); + } + } + /** * Test that remote inboxes still use HTTP. *