PluginProbe
ActivityPub / 3.2.2
ActivityPub v3.2.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/rest/class-server.php +73 -248 9.0.2 → 3.2.2 View file →
@@ -1,17 +1,15 @@
1 1 <?php
2 -/**
3 - * Server REST-Class file.
4 - *
5 - * @package Activitypub
6 - */
7 -
8 2 namespace Activitypub\Rest;
9 3
4 +use stdClass;
5 +use WP_Error;
6 +use WP_REST_Response;
10 7 use Activitypub\Signature;
8 +use Activitypub\Model\Application;
11 9
12 10 /**
13 - * ActivityPub Server REST-Class.
11 + * ActivityPub Server REST-Class
14 12 *
15 13 * @author Django Doucet
16 14 *
17 15 * @see https://www.w3.org/TR/activitypub/#security-verification
@@ -17,286 +15,113 @@
17 15 * @see https://www.w3.org/TR/activitypub/#security-verification
18 16 */
19 17 class Server {
20 18 /**
21 - * Initialize the class, registering WordPress hooks.
19 + * Initialize the class, registering WordPress hooks
22 20 */
23 21 public static function init() {
24 - \add_filter( 'rest_pre_dispatch', array( self::class, 'maybe_add_actor_from_signature' ), 10, 3 );
25 - \add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
26 - \add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
22 + self::register_routes();
27 23
28 - \add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 );
29 - \add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 );
30 - \add_filter( 'rest_allowed_cors_headers', array( self::class, 'allow_cors_headers' ), 10, 2 );
24 + \add_filter( 'rest_request_before_callbacks', array( self::class, 'authorize_activitypub_requests' ), 10, 3 );
31 25 }
32 26
33 27 /**
34 - * Callback function to validate incoming ActivityPub requests
35 - *
36 - * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Result to send to the client.
37 - * Usually a WP_REST_Response or WP_Error.
38 - * @param array $handler Route handler used for the request.
39 - * @param \WP_REST_Request $request Request used to generate the response.
40 - *
41 - * @return mixed|\WP_Error The response, error, or modified response.
28 + * Register routes
42 29 */
43 - public static function validate_requests( $response, $handler, $request ) {
44 - if ( 'HEAD' === $request->get_method() ) {
45 - return $response;
46 - }
47 -
48 - $route = $request->get_route();
49 -
50 - if (
51 - \is_wp_error( $response ) ||
52 - ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE )
53 - ) {
54 - return $response;
55 - }
56 -
57 - $params = $request->get_json_params();
58 -
59 - // Type is required for ActivityPub requests, so it fail later in the process.
60 - if ( ! isset( $params['type'] ) ) {
61 - return $response;
62 - }
63 -
64 - if (
65 - ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
66 - in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
67 - ) {
68 - return new \WP_Error(
69 - 'activitypub_server_does_not_accept_incoming_interactions',
70 - \__( 'This server does not accept incoming interactions.', 'activitypub' ),
71 - // We have to use a 2XX status code here, because otherwise the response will be
72 - // treated as an error and Mastodon might block this WordPress instance.
73 - array( 'status' => 202 )
74 - );
75 - }
76 -
77 - return $response;
30 + public static function register_routes() {
31 + \register_rest_route(
32 + ACTIVITYPUB_REST_NAMESPACE,
33 + '/application',
34 + array(
35 + array(
36 + 'methods' => \WP_REST_Server::READABLE,
37 + 'callback' => array( self::class, 'application_actor' ),
38 + 'permission_callback' => '__return_true',
39 + ),
40 + )
41 + );
78 42 }
79 43
80 44 /**
81 - * Modify the parameter priority order for a REST API request.
45 + * Render Application actor profile
82 46 *
83 - * @param string[] $order Array of types to check, in order of priority.
84 - * @param \WP_REST_Request $request The request object.
85 - *
86 - * @return string[] The modified order of types to check.
47 + * @return WP_REST_Response The JSON profile of the Application Actor.
87 48 */
88 - public static function request_parameter_order( $order, $request ) {
89 - $route = $request->get_route();
49 + public static function application_actor() {
50 + $user = new Application();
90 51
91 - // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
92 - if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
93 - return $order;
94 - }
52 + $json = $user->to_array();
95 53
96 - $method = $request->get_method();
54 + $rest_response = new WP_REST_Response( $json, 200 );
55 + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
97 56
98 - if ( \WP_REST_Server::CREATABLE !== $method ) {
99 - return $order;
100 - }
101 -
102 - return array(
103 - 'JSON',
104 - 'POST',
105 - 'URL',
106 - 'defaults',
107 - );
57 + return $rest_response;
108 58 }
109 59
110 60 /**
111 - * Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
61 + * Callback function to authorize each api requests
112 62 *
113 - * Mastodon (FEP-7aa9) omits `actor` from the FeatureRequest body and conveys the
114 - * requesting actor only through the HTTP signature keyId. Our inbox routes require
115 - * `actor`, so such a request is rejected during parameter validation before it can
116 - * reach the inbox or its handler, which is why no Accept is ever sent.
63 + * @see WP_REST_Request
117 64 *
118 - * Derive the actor from the keyId and add it as a request parameter. The actor is
119 - * injected with `set_param()` rather than by rewriting the request body, so the raw
120 - * body stays byte-identical and the signed `Digest` still verifies. Inbox POSTs read
121 - * JSON parameters first (see `request_parameter_order()`), so the value is visible to
122 - * both parameter validation and the handler via `get_json_params()`.
65 + * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
66 + * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
123 67 *
124 - * Scoped to FeatureRequest, the only activity type known to address this way. Runs on
125 - * `rest_pre_dispatch` because that is the only hook that fires before required-parameter
126 - * validation. Signature verification still runs afterwards and remains authoritative:
127 - * the injected actor is derived from the very keyId the signature is checked against, so
128 - * it cannot be used to impersonate another actor.
68 + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
69 + * Usually a WP_REST_Response or WP_Error.
70 + * @param array $handler Route handler used for the request.
71 + * @param WP_REST_Request $request Request used to generate the response.
129 72 *
130 - * @since 9.0.0
131 - *
132 - * @param mixed $result Response to replace the request with, or null to continue.
133 - * @param \WP_REST_Server $server Server instance.
134 - * @param \WP_REST_Request $request The request object.
135 - *
136 - * @return mixed The unmodified `$result`.
73 + * @return mixed|WP_Error The response, error, or modified response.
137 74 */
138 - public static function maybe_add_actor_from_signature( $result, $server, $request ) {
139 - // Respect an earlier short-circuit.
140 - if ( null !== $result ) {
141 - return $result;
75 + public static function authorize_activitypub_requests( $response, $handler, $request ) {
76 + if ( 'HEAD' === $request->get_method() ) {
77 + return $response;
142 78 }
143 79
144 - if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
145 - return $result;
146 - }
80 + $route = $request->get_route();
147 81
148 - $route = $request->get_route();
82 + // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints
149 83 if (
150 84 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
151 - ! \str_ends_with( $route, '/inbox' )
85 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) ||
86 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' ) ||
87 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'application' )
152 88 ) {
153 - return $result;
154 - }
155 -
156 - $json = $request->get_json_params();
157 - if ( ! \is_array( $json ) || 'FeatureRequest' !== ( $json['type'] ?? '' ) || ! empty( $json['actor'] ) ) {
158 - return $result;
159 - }
160 -
161 - $key_id = Signature::get_key_id( $request );
162 - if ( ! $key_id ) {
163 - return $result;
164 - }
165 -
166 - $request->set_param( 'actor', \strip_fragment_from_url( $key_id ) );
167 -
168 - return $result;
169 - }
170 -
171 - /**
172 - * Filters the REST API response to properly handle the ActivityPub error formatting.
173 - *
174 - * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/c180/fep-c180.md
175 - *
176 - * @param \WP_HTTP_Response $response Result to send to the client. Usually a `WP_REST_Response`.
177 - * @param \WP_REST_Server $server Server instance.
178 - * @param \WP_REST_Request $request Request used to generate the response.
179 - *
180 - * @return \WP_HTTP_Response The filtered response.
181 - */
182 - public static function filter_output( $response, $server, $request ) {
183 - $route = $request->get_route();
184 -
185 - // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
186 - if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
187 89 return $response;
188 90 }
189 91
190 - // Exclude OAuth endpoints - they have their own error format per RFC 6749.
191 - if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) {
192 - return $response;
193 - }
92 + /**
93 + * Filter to defer signature verification
94 + *
95 + * Skip signature verification for debugging purposes or to reduce load for
96 + * certain Activity-Types, like "Delete".
97 + *
98 + * @param bool $defer Whether to defer signature verification.
99 + * @param WP_REST_Request $request The request used to generate the response.
100 + *
101 + * @return bool Whether to defer signature verification.
102 + */
103 + $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
194 104
195 - // Only alter responses that return an error status code.
196 - if ( $response->get_status() < 400 ) {
105 + if ( $defer ) {
197 106 return $response;
198 107 }
199 108
200 - $data = $response->get_data();
201 -
202 - // Ensure that `$data` was already converted to a response.
203 - if ( \is_wp_error( $data ) ) {
204 - $response = \rest_convert_error_to_response( $data );
205 - $data = $response->get_data();
109 + if (
110 + // POST-Requests are always signed
111 + 'GET' !== $request->get_method() ||
112 + // GET-Requests only require a signature in secure mode
113 + ( 'GET' === $request->get_method() && ACTIVITYPUB_AUTHORIZED_FETCH )
114 + ) {
115 + $verified_request = Signature::verify_http_signature( $request );
116 + if ( \is_wp_error( $verified_request ) ) {
117 + return new WP_Error(
118 + 'activitypub_signature_verification',
119 + $verified_request->get_error_message(),
120 + array( 'status' => 401 )
121 + );
122 + }
206 123 }
207 124
208 - $error = array(
209 - 'type' => 'about:blank',
210 - 'title' => $data['code'] ?? '',
211 - 'detail' => $data['message'] ?? '',
212 - 'status' => $response->get_status(),
213 -
214 - /*
215 - * Provides the unstructured error data.
216 - *
217 - * @see https://nodeinfo.diaspora.software/schema.html#metadata.
218 - */
219 - 'metadata' => $data,
220 - );
221 -
222 - $response->set_data( $error );
223 -
224 125 return $response;
225 - }
226 -
227 - /**
228 - * Add CORS headers to ActivityPub REST responses.
229 - *
230 - * @param \WP_REST_Response $response The REST response.
231 - * @param \WP_REST_Server $server The REST server instance.
232 - * @param \WP_REST_Request $request The request object.
233 - *
234 - * @return \WP_REST_Response The modified response.
235 - */
236 - public static function add_cors_headers( $response, $server, $request ) {
237 - $route = $request->get_route();
238 - $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
239 -
240 - // Only add CORS to ActivityPub endpoints, except the interactive OAuth authorize endpoint.
241 - if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
242 - return $response;
243 - }
244 -
245 - /*
246 - * ActivityPub data is meant to be publicly readable by federation peers
247 - * and browser-side clients. We do not enable credentialed cross-origin
248 - * access: cookie auth would still be rejected by WordPress core's
249 - * REST nonce check, and OAuth Bearer tokens travel in the
250 - * Authorization header — which is permitted via Allow-Headers and
251 - * does not require Allow-Credentials.
252 - *
253 - * Allow-Headers is contributed by core (which already lists `X-WP-Nonce`,
254 - * `Authorization`, `Content-Type`, `Content-Disposition`, and `Content-MD5`)
255 - * and extended for ActivityPub via the `rest_allowed_cors_headers` filter
256 - * in self::allow_cors_headers().
257 - */
258 - $response->header( 'Access-Control-Allow-Origin', '*' );
259 - $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' );
260 -
261 - return $response;
262 - }
263 -
264 - /**
265 - * Extend the CORS Allow-Headers list for ActivityPub REST endpoints.
266 - *
267 - * Adds the headers ActivityPub clients need on top of WordPress core's
268 - * defaults: `Accept` for content negotiation and `Last-Event-ID` for
269 - * Server-Sent Events resume.
270 - *
271 - * @since 8.3.0
272 - *
273 - * @param string[] $allow_headers Headers core currently permits in CORS requests.
274 - * @param \WP_REST_Request $request The current REST request.
275 - *
276 - * @return string[] The (possibly extended) list of allowed headers.
277 - */
278 - public static function allow_cors_headers( $allow_headers, $request ) {
279 - $route = $request->get_route();
280 - $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
281 -
282 - if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
283 - return $allow_headers;
284 - }
285 -
286 - return \array_values( \array_unique( \array_merge( (array) $allow_headers, array( 'Accept', 'Last-Event-ID' ) ) ) );
287 - }
288 -
289 - /**
290 - * Send CORS headers directly via header().
291 - *
292 - * Use this for endpoints that bypass the REST response flow
293 - * (e.g. SSE streams that call exit() instead of returning a WP_REST_Response).
294 - *
295 - * @since 8.1.0
296 - */
297 - public static function send_cors_headers() {
298 - \header( 'Access-Control-Allow-Origin: *' );
299 - \header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
300 - \header( 'Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type, Accept, Last-Event-ID' );
301 126 }
302 127 }