From 8698629ad6c53f141678b54afe1ab877e1a69425 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 14:33:18 +0100 Subject: [PATCH 1/6] Add `wp activitypub fetch` CLI command. Introduces a new WP-CLI command for fetching remote ActivityPub URLs with signed HTTP requests. Supports switching between signature modes (draft-cavage, rfc9421, none) for debugging federation issues. --- includes/class-cli.php | 9 ++ includes/cli/class-fetch-command.php | 175 +++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 includes/cli/class-fetch-command.php diff --git a/includes/class-cli.php b/includes/class-cli.php index 34a73565b4..09aacd7498 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -30,6 +30,7 @@ class Cli { * - wp activitypub self-destruct [--status] [--yes] * - wp activitypub move * - wp activitypub follow + * - wp activitypub fetch */ public static function register() { // Register parent command with version subcommand. @@ -96,5 +97,13 @@ public static function register() { 'shortdesc' => 'Follow a remote ActivityPub user.', ) ); + + \WP_CLI::add_command( + 'activitypub fetch', + '\Activitypub\Cli\Fetch_Command', + array( + 'shortdesc' => 'Fetch a remote URL with a signed ActivityPub request.', + ) + ); } } diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php new file mode 100644 index 0000000000..b2dea340a4 --- /dev/null +++ b/includes/cli/class-fetch-command.php @@ -0,0 +1,175 @@ + + * : The URL to fetch. + * + * [--signature=] + * : Signature mode: draft-cavage, rfc9421, or none. + * --- + * default: default + * options: + * - default + * - draft-cavage + * - rfc9421 + * - none + * --- + * + * [--raw] + * : Output the raw response body without formatting. + * + * [--include-headers] + * : Show response headers alongside the body. + * + * ## EXAMPLES + * + * # Fetch an actor profile with default signature + * $ wp activitypub fetch https://mastodon.social/@Gargron + * + * # Fetch with RFC 9421 signature + * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=rfc9421 + * + * # Fetch with Draft Cavage signature + * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=draft-cavage + * + * # Fetch without signature + * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=none + * + * # Show response headers + * $ wp activitypub fetch https://mastodon.social/@Gargron --include-headers + * + * # Output raw response body + * $ wp activitypub fetch https://mastodon.social/@Gargron --raw + * + * @param array $args The positional arguments. + * @param array $assoc_args The associative arguments. + */ + public function __invoke( $args, $assoc_args ) { + $url = $args[0]; + $signature_mode = \WP_CLI\Utils\get_flag_value( $assoc_args, 'signature', 'default' ); + $raw = \WP_CLI\Utils\get_flag_value( $assoc_args, 'raw', false ); + $include_headers = \WP_CLI\Utils\get_flag_value( $assoc_args, 'include-headers', false ); + + \WP_CLI::log( \sprintf( 'Fetching: %s', $url ) ); + \WP_CLI::log( \sprintf( 'Signature mode: %s', $signature_mode ) ); + + $get_args = array(); + $cleanup = $this->apply_signature_mode( $signature_mode, $get_args ); + $response = Http::get( $url, $get_args, false ); + + $cleanup(); + + if ( \is_wp_error( $response ) ) { + \WP_CLI::error( \sprintf( 'Request failed (HTTP %s).', $response->get_error_code() ) ); + } + + $code = \wp_remote_retrieve_response_code( $response ); + + \WP_CLI::log( \sprintf( 'Response code: %d', $code ) ); + \WP_CLI::log( '' ); + + // Show response headers if requested. + if ( $include_headers ) { + $headers = \wp_remote_retrieve_headers( $response ); + + \WP_CLI::log( '--- Response Headers ---' ); + + foreach ( $headers as $name => $value ) { + \WP_CLI::log( \sprintf( '%s: %s', $name, $value ) ); + } + + \WP_CLI::log( '' ); + } + + $body = \wp_remote_retrieve_body( $response ); + + // Output the body. + if ( $raw ) { + \WP_CLI::log( $body ); + } else { + $data = \json_decode( $body, true ); + + if ( $data ) { + \WP_CLI::log( \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); + } else { + \WP_CLI::log( $body ); + } + } + } + + /** + * Apply signature mode overrides via option filters. + * + * @param string $mode The signature mode. + * @param array $args The request arguments, passed by reference. + * + * @return callable Cleanup callback to remove added filters. + */ + private function apply_signature_mode( $mode, &$args ) { + $filters = array(); + + switch ( $mode ) { + case 'none': + $args['key_id'] = null; + $args['private_key'] = null; + break; + + case 'rfc9421': + $force_rfc9421 = function () { + return '1'; + }; + $bypass_unsupported = function () { + return array(); + }; + + \add_filter( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); + \add_filter( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + + $filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); + $filters[] = array( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + break; + + case 'draft-cavage': + $force_cavage = function () { + return '0'; + }; + + \add_filter( 'pre_option_activitypub_rfc9421_signature', $force_cavage ); + + $filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_cavage ); + break; + } + + return function () use ( $filters ) { + foreach ( $filters as $filter ) { + \remove_filter( $filter[0], $filter[1] ); + } + }; + } +} From 689d5b7c9c4e6e1935f5231093ff415484d956b6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 14:39:57 +0100 Subject: [PATCH 2/6] Address code review feedback. - Disable maybe_double_knock when forcing rfc9421 to prevent marking hosts as unsupported. - Include error message in failure output. - Use strict null check for json_decode result. --- includes/cli/class-fetch-command.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php index b2dea340a4..1cbb0c5ff6 100644 --- a/includes/cli/class-fetch-command.php +++ b/includes/cli/class-fetch-command.php @@ -8,6 +8,7 @@ namespace Activitypub\Cli; use Activitypub\Http; +use Activitypub\Signature; /** * Fetch a remote ActivityPub URL with signed HTTP requests. @@ -86,7 +87,7 @@ public function __invoke( $args, $assoc_args ) { $cleanup(); if ( \is_wp_error( $response ) ) { - \WP_CLI::error( \sprintf( 'Request failed (HTTP %s).', $response->get_error_code() ) ); + \WP_CLI::error( \sprintf( 'Request failed: %s (HTTP %s).', $response->get_error_message(), $response->get_error_code() ) ); } $code = \wp_remote_retrieve_response_code( $response ); @@ -115,7 +116,7 @@ public function __invoke( $args, $assoc_args ) { } else { $data = \json_decode( $body, true ); - if ( $data ) { + if ( null !== $data ) { \WP_CLI::log( \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } else { \WP_CLI::log( $body ); @@ -133,6 +134,7 @@ public function __invoke( $args, $assoc_args ) { */ private function apply_signature_mode( $mode, &$args ) { $filters = array(); + $restore = array(); switch ( $mode ) { case 'none': @@ -150,9 +152,11 @@ private function apply_signature_mode( $mode, &$args ) { \add_filter( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); \add_filter( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); $filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); $filters[] = array( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + $restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 ); break; case 'draft-cavage': @@ -166,10 +170,13 @@ private function apply_signature_mode( $mode, &$args ) { break; } - return function () use ( $filters ) { + return function () use ( $filters, $restore ) { foreach ( $filters as $filter ) { \remove_filter( $filter[0], $filter[1] ); } + foreach ( $restore as $filter ) { + \add_filter( $filter[0], $filter[1], $filter[2], $filter[3] ); + } }; } } From 39e553569c15adcc0ce0003cea119275c0488e7f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 14:44:01 +0100 Subject: [PATCH 3/6] Fix infinite loop when forcing rfc9421 signature mode. When pre_option forced RFC 9421 and the server returned 4xx, maybe_double_knock retried with Draft Cavage, but sign_request re-signed with RFC 9421 (due to the active filter), causing an infinite loop. Fix by replacing sign_request entirely for forced rfc9421 mode and disabling maybe_double_knock during the request. --- includes/cli/class-fetch-command.php | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php index 1cbb0c5ff6..ad391b37b6 100644 --- a/includes/cli/class-fetch-command.php +++ b/includes/cli/class-fetch-command.php @@ -9,6 +9,7 @@ use Activitypub\Http; use Activitypub\Signature; +use Activitypub\Signature\Http_Message_Signature; /** * Fetch a remote ActivityPub URL with signed HTTP requests. @@ -125,12 +126,15 @@ public function __invoke( $args, $assoc_args ) { } /** - * Apply signature mode overrides via option filters. + * Apply signature mode overrides via filters. + * + * For rfc9421, replaces the default sign_request and disables double-knock + * to avoid an infinite retry loop when the server returns 4xx. * * @param string $mode The signature mode. * @param array $args The request arguments, passed by reference. * - * @return callable Cleanup callback to remove added filters. + * @return callable Cleanup callback to restore original filters. */ private function apply_signature_mode( $mode, &$args ) { $filters = array(); @@ -143,19 +147,21 @@ private function apply_signature_mode( $mode, &$args ) { break; case 'rfc9421': - $force_rfc9421 = function () { - return '1'; - }; - $bypass_unsupported = function () { - return array(); - }; - - \add_filter( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); - \add_filter( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + // Replace default signing to force RFC 9421 and prevent + // double-knock from re-signing with Draft Cavage in a loop. + \remove_filter( 'http_request_args', array( Signature::class, 'sign_request' ), 0 ); \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); - $filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_rfc9421 ); - $filters[] = array( 'pre_option_activitypub_rfc9421_unsupported', $bypass_unsupported ); + $forced_signer = function ( $request_args, $url ) { + if ( ! isset( $request_args['key_id'], $request_args['private_key'] ) ) { + return $request_args; + } + return ( new Http_Message_Signature() )->sign( $request_args, $url ); + }; + \add_filter( 'http_request_args', $forced_signer, 0, 2 ); + + $filters[] = array( 'http_request_args', $forced_signer, 0 ); + $restore[] = array( 'http_request_args', array( Signature::class, 'sign_request' ), 0, 2 ); $restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 ); break; @@ -172,7 +178,7 @@ private function apply_signature_mode( $mode, &$args ) { return function () use ( $filters, $restore ) { foreach ( $filters as $filter ) { - \remove_filter( $filter[0], $filter[1] ); + \remove_filter( $filter[0], $filter[1], $filter[2] ?? 10 ); } foreach ( $restore as $filter ) { \add_filter( $filter[0], $filter[1], $filter[2], $filter[3] ); From 1186aadd57d2547c1fee6275487c97966e915ce5 Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Wed, 11 Feb 2026 15:44:58 +0200 Subject: [PATCH 4/6] Add changelog --- .github/changelog/2906-from-description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/2906-from-description diff --git a/.github/changelog/2906-from-description b/.github/changelog/2906-from-description new file mode 100644 index 0000000000..33fc5473ec --- /dev/null +++ b/.github/changelog/2906-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add `wp activitypub fetch` CLI command for fetching remote URLs with signed HTTP requests. From 48d0830a3edb1c96c891ae4a8f9f6839bf6eaadd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 14:46:54 +0100 Subject: [PATCH 5/6] Add double-knock signature mode. Tries RFC 9421 first, falls back to Draft Cavage on 4xx via maybe_double_knock. Avoids the infinite loop by skipping re-signing when the request is already signed by the retry. --- includes/cli/class-fetch-command.php | 30 ++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php index ad391b37b6..4260e21b83 100644 --- a/includes/cli/class-fetch-command.php +++ b/includes/cli/class-fetch-command.php @@ -33,13 +33,14 @@ class Fetch_Command extends \WP_CLI_Command { * : The URL to fetch. * * [--signature=] - * : Signature mode: draft-cavage, rfc9421, or none. + * : Signature mode: draft-cavage, rfc9421, double-knock, or none. * --- * default: default * options: * - default * - draft-cavage * - rfc9421 + * - double-knock * - none * --- * @@ -60,6 +61,9 @@ class Fetch_Command extends \WP_CLI_Command { * # Fetch with Draft Cavage signature * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=draft-cavage * + * # Fetch with double-knock (RFC 9421 first, Draft Cavage fallback on 4xx) + * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=double-knock + * * # Fetch without signature * $ wp activitypub fetch https://mastodon.social/@Gargron --signature=none * @@ -147,22 +151,36 @@ private function apply_signature_mode( $mode, &$args ) { break; case 'rfc9421': - // Replace default signing to force RFC 9421 and prevent - // double-knock from re-signing with Draft Cavage in a loop. + case 'double-knock': + // Replace default signing to force RFC 9421. For rfc9421 mode, + // also disable double-knock to prevent an infinite retry loop. + // For double-knock mode, keep it active but skip re-signing on retry. \remove_filter( 'http_request_args', array( Signature::class, 'sign_request' ), 0 ); - \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); - $forced_signer = function ( $request_args, $url ) { + $is_double_knock = 'double-knock' === $mode; + + if ( ! $is_double_knock ) { + \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); + } + + $forced_signer = function ( $request_args, $url ) use ( $is_double_knock ) { if ( ! isset( $request_args['key_id'], $request_args['private_key'] ) ) { return $request_args; } + // In double-knock mode, skip if already signed (retry from maybe_double_knock). + if ( $is_double_knock && ! empty( $request_args['headers']['Signature'] ) ) { + return $request_args; + } return ( new Http_Message_Signature() )->sign( $request_args, $url ); }; \add_filter( 'http_request_args', $forced_signer, 0, 2 ); $filters[] = array( 'http_request_args', $forced_signer, 0 ); $restore[] = array( 'http_request_args', array( Signature::class, 'sign_request' ), 0, 2 ); - $restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 ); + + if ( ! $is_double_knock ) { + $restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 ); + } break; case 'draft-cavage': From 465f1327e12f2ab68ff3e7f75fb180081a314acf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 14:58:40 +0100 Subject: [PATCH 6/6] Address code review feedback for fetch CLI command. Fix error message labeling WP_Error codes as HTTP codes, use json_last_error() for reliable JSON detection, document the default signature mode, validate invalid modes with early error, and only restore filters that were actually removed. --- includes/cli/class-fetch-command.php | 31 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/includes/cli/class-fetch-command.php b/includes/cli/class-fetch-command.php index 4260e21b83..8125c4bb29 100644 --- a/includes/cli/class-fetch-command.php +++ b/includes/cli/class-fetch-command.php @@ -33,7 +33,7 @@ class Fetch_Command extends \WP_CLI_Command { * : The URL to fetch. * * [--signature=] - * : Signature mode: draft-cavage, rfc9421, double-knock, or none. + * : Signature mode: default (plugin-configured), draft-cavage, rfc9421, double-knock, or none. * --- * default: default * options: @@ -92,7 +92,7 @@ public function __invoke( $args, $assoc_args ) { $cleanup(); if ( \is_wp_error( $response ) ) { - \WP_CLI::error( \sprintf( 'Request failed: %s (HTTP %s).', $response->get_error_message(), $response->get_error_code() ) ); + \WP_CLI::error( \sprintf( 'Request failed: %s (Error code: %s).', $response->get_error_message(), $response->get_error_code() ) ); } $code = \wp_remote_retrieve_response_code( $response ); @@ -121,7 +121,7 @@ public function __invoke( $args, $assoc_args ) { } else { $data = \json_decode( $body, true ); - if ( null !== $data ) { + if ( \JSON_ERROR_NONE === \json_last_error() ) { \WP_CLI::log( \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } else { \WP_CLI::log( $body ); @@ -145,6 +145,9 @@ private function apply_signature_mode( $mode, &$args ) { $restore = array(); switch ( $mode ) { + case 'default': + break; + case 'none': $args['key_id'] = null; $args['private_key'] = null; @@ -155,12 +158,13 @@ private function apply_signature_mode( $mode, &$args ) { // Replace default signing to force RFC 9421. For rfc9421 mode, // also disable double-knock to prevent an infinite retry loop. // For double-knock mode, keep it active but skip re-signing on retry. - \remove_filter( 'http_request_args', array( Signature::class, 'sign_request' ), 0 ); + $removed_sign_request = \remove_filter( 'http_request_args', array( Signature::class, 'sign_request' ), 0 ); - $is_double_knock = 'double-knock' === $mode; + $is_double_knock = 'double-knock' === $mode; + $removed_double_knock = false; if ( ! $is_double_knock ) { - \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); + $removed_double_knock = \remove_filter( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10 ); } $forced_signer = function ( $request_args, $url ) use ( $is_double_knock ) { @@ -176,9 +180,12 @@ private function apply_signature_mode( $mode, &$args ) { \add_filter( 'http_request_args', $forced_signer, 0, 2 ); $filters[] = array( 'http_request_args', $forced_signer, 0 ); - $restore[] = array( 'http_request_args', array( Signature::class, 'sign_request' ), 0, 2 ); - if ( ! $is_double_knock ) { + if ( $removed_sign_request ) { + $restore[] = array( 'http_request_args', array( Signature::class, 'sign_request' ), 0, 2 ); + } + + if ( $removed_double_knock ) { $restore[] = array( 'http_response', array( Signature::class, 'maybe_double_knock' ), 10, 3 ); } break; @@ -192,6 +199,14 @@ private function apply_signature_mode( $mode, &$args ) { $filters[] = array( 'pre_option_activitypub_rfc9421_signature', $force_cavage ); break; + + default: + \WP_CLI::error( + \sprintf( + 'Invalid signature mode "%s". Allowed modes: default, draft-cavage, rfc9421, double-knock, none.', + $mode + ) + ); } return function () use ( $filters, $restore ) {