PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.1
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 +331 -69 1.2.09.2.1 View file →
@@ -1,15 +1,20 @@
1 1 <?php
2 +/**
3 + * Server REST-Class file.
4 + *
5 + * @package Activitypub
6 + */
7 +
2 8 namespace Activitypub\Rest;
3 9
4 -use stdClass;
5 -use WP_Error;
6 -use WP_REST_Response;
7 10 use Activitypub\Signature;
8 -use Activitypub\Model\Application_User;
9 11
12 +use function Activitypub\maybe_set_no_store;
13 +use function Activitypub\use_authorized_fetch;
14 +
10 15 /**
11 - * ActivityPub Server REST-Class
16 + * ActivityPub Server REST-Class.
12 17 *
13 18 * @author Django Doucet
14 19 *
15 20 * @see https://www.w3.org/TR/activitypub/#security-verification
@@ -15,112 +20,369 @@
15 20 * @see https://www.w3.org/TR/activitypub/#security-verification
16 21 */
17 22 class Server {
18 23 /**
19 - * Initialize the class, registering WordPress hooks
24 + * Initialize the class, registering WordPress hooks.
20 25 */
21 26 public static function init() {
22 - self::register_routes();
27 + \add_filter( 'rest_pre_dispatch', array( self::class, 'maybe_add_actor_from_signature' ), 10, 3 );
28 + \add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
29 + \add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
23 30
24 - \add_filter( 'rest_request_before_callbacks', array( self::class, 'authorize_activitypub_requests' ), 10, 3 );
31 + \add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 );
32 + \add_filter( 'rest_post_dispatch', array( self::class, 'add_cache_headers' ), 10, 3 );
33 + \add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 );
34 + \add_filter( 'rest_allowed_cors_headers', array( self::class, 'allow_cors_headers' ), 10, 2 );
25 35 }
26 36
27 37 /**
28 - * Register routes
38 + * Callback function to validate incoming ActivityPub requests
39 + *
40 + * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Result to send to the client.
41 + * Usually a WP_REST_Response or WP_Error.
42 + * @param array $handler Route handler used for the request.
43 + * @param \WP_REST_Request $request Request used to generate the response.
44 + *
45 + * @return mixed|\WP_Error The response, error, or modified response.
29 46 */
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 - );
47 + public static function validate_requests( $response, $handler, $request ) {
48 + if ( 'HEAD' === $request->get_method() ) {
49 + return $response;
50 + }
51 +
52 + $route = $request->get_route();
53 +
54 + if (
55 + \is_wp_error( $response ) ||
56 + ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE )
57 + ) {
58 + return $response;
59 + }
60 +
61 + $params = $request->get_json_params();
62 +
63 + // Type is required for ActivityPub requests, so it fail later in the process.
64 + if ( ! isset( $params['type'] ) ) {
65 + return $response;
66 + }
67 +
68 + if (
69 + ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
70 + \in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
71 + ) {
72 + return new \WP_Error(
73 + 'activitypub_server_does_not_accept_incoming_interactions',
74 + \__( 'This server does not accept incoming interactions.', 'activitypub' ),
75 + // We have to use a 2XX status code here, because otherwise the response will be
76 + // treated as an error and Mastodon might block this WordPress instance.
77 + array( 'status' => 202 )
78 + );
79 + }
80 +
81 + return $response;
42 82 }
43 83
44 84 /**
45 - * Render Application actor profile
85 + * Modify the parameter priority order for a REST API request.
46 86 *
47 - * @return WP_REST_Response The JSON profile of the Application Actor.
87 + * @param string[] $order Array of types to check, in order of priority.
88 + * @param \WP_REST_Request $request The request object.
89 + *
90 + * @return string[] The modified order of types to check.
48 91 */
49 - public static function application_actor() {
50 - $user = new Application_User();
92 + public static function request_parameter_order( $order, $request ) {
93 + $route = $request->get_route();
51 94
52 - $user->set_context(
53 - \Activitypub\Activity\Activity::CONTEXT
54 - );
95 + // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
96 + if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
97 + return $order;
98 + }
55 99
56 - $json = $user->to_array();
100 + $method = $request->get_method();
57 101
58 - $rest_response = new WP_REST_Response( $json, 200 );
59 - $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
102 + if ( \WP_REST_Server::CREATABLE !== $method ) {
103 + return $order;
104 + }
60 105
61 - return $rest_response;
106 + return array(
107 + 'JSON',
108 + 'POST',
109 + 'URL',
110 + 'defaults',
111 + );
62 112 }
63 113
64 114 /**
65 - * Callback function to authorize each api requests
115 + * Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
66 116 *
67 - * @see WP_REST_Request
117 + * Mastodon (FEP-7aa9) omits `actor` from the FeatureRequest body and conveys the
118 + * requesting actor only through the HTTP signature keyId. Our inbox routes require
119 + * `actor`, so such a request is rejected during parameter validation before it can
120 + * reach the inbox or its handler, which is why no Accept is ever sent.
68 121 *
69 - * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
70 - * Usually a WP_REST_Response or WP_Error.
71 - * @param array $handler Route handler used for the request.
72 - * @param WP_REST_Request $request Request used to generate the response.
122 + * Derive the actor from the keyId and add it as a request parameter. The actor is
123 + * injected with `set_param()` rather than by rewriting the request body, so the raw
124 + * body stays byte-identical and the signed `Digest` still verifies. Inbox POSTs read
125 + * JSON parameters first (see `request_parameter_order()`), so the value is visible to
126 + * both parameter validation and the handler via `get_json_params()`.
73 127 *
74 - * @return mixed|WP_Error The response, error, or modified response.
128 + * Scoped to FeatureRequest, the only activity type known to address this way. Runs on
129 + * `rest_pre_dispatch` because that is the only hook that fires before required-parameter
130 + * validation. Signature verification still runs afterwards and remains authoritative:
131 + * the injected actor is derived from the very keyId the signature is checked against, so
132 + * it cannot be used to impersonate another actor.
133 + *
134 + * @since 9.0.0
135 + *
136 + * @param mixed $result Response to replace the request with, or null to continue.
137 + * @param \WP_REST_Server $server Server instance.
138 + * @param \WP_REST_Request $request The request object.
139 + *
140 + * @return mixed The unmodified `$result`.
75 141 */
76 - public static function authorize_activitypub_requests( $response, $handler, $request ) {
77 - if ( 'HEAD' === $request->get_method() ) {
78 - return $response;
142 + public static function maybe_add_actor_from_signature( $result, $server, $request ) {
143 + // Respect an earlier short-circuit.
144 + if ( null !== $result ) {
145 + return $result;
79 146 }
80 147
148 + if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
149 + return $result;
150 + }
151 +
81 152 $route = $request->get_route();
82 -
83 - // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints
84 153 if (
85 154 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
86 - \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) ||
87 - \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' )
155 + ! \str_ends_with( $route, '/inbox' )
88 156 ) {
157 + return $result;
158 + }
159 +
160 + $json = $request->get_json_params();
161 + if ( ! \is_array( $json ) || 'FeatureRequest' !== ( $json['type'] ?? '' ) || ! empty( $json['actor'] ) ) {
162 + return $result;
163 + }
164 +
165 + $key_id = Signature::get_key_id( $request );
166 + if ( ! $key_id ) {
167 + return $result;
168 + }
169 +
170 + $request->set_param( 'actor', \strip_fragment_from_url( $key_id ) );
171 +
172 + return $result;
173 + }
174 +
175 + /**
176 + * Filters the REST API response to properly handle the ActivityPub error formatting.
177 + *
178 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/c180/fep-c180.md
179 + *
180 + * @param \WP_HTTP_Response $response Result to send to the client. Usually a `WP_REST_Response`.
181 + * @param \WP_REST_Server $server Server instance.
182 + * @param \WP_REST_Request $request Request used to generate the response.
183 + *
184 + * @return \WP_HTTP_Response The filtered response.
185 + */
186 + public static function filter_output( $response, $server, $request ) {
187 + $route = $request->get_route();
188 +
189 + // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
190 + if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
89 191 return $response;
90 192 }
91 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.
194 + // Exclude OAuth endpoints - they have their own error format per RFC 6749.
195 + if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) {
196 + return $response;
197 + }
198 +
199 + // Only alter responses that return an error status code.
200 + if ( $response->get_status() < 400 ) {
201 + return $response;
202 + }
203 +
204 + $data = $response->get_data();
205 +
206 + // Ensure that `$data` was already converted to a response.
207 + if ( \is_wp_error( $data ) ) {
208 + $response = \rest_convert_error_to_response( $data );
209 + $data = $response->get_data();
210 + }
211 +
212 + $error = array(
213 + 'type' => 'about:blank',
214 + 'title' => $data['code'] ?? '',
215 + 'detail' => $data['message'] ?? '',
216 + 'status' => $response->get_status(),
217 +
218 + /*
219 + * Provides the unstructured error data.
220 + *
221 + * @see https://nodeinfo.diaspora.software/schema.html#metadata.
222 + */
223 + 'metadata' => $data,
224 + );
225 +
226 + $response->set_data( $error );
227 +
228 + return $response;
229 + }
230 +
231 + /**
232 + * Add cache headers to ActivityPub responses.
233 + *
234 + * Responses on these routes are not the same for every caller: Authorized Fetch
235 + * varies them by signing key, and the ActivityPub API varies them by access token.
236 + * Shared caches are told to key on those headers, and a response produced for a
237 + * caller that presented credentials is marked as belonging to that caller alone.
238 + *
239 + * @since 9.2.0
240 + *
241 + * @param \WP_REST_Response $response Result to send to the client.
242 + * @param \WP_REST_Server $server Server instance.
243 + * @param \WP_REST_Request $request Request used to generate the response.
244 + *
245 + * @return \WP_REST_Response The response object.
246 + */
247 + public static function add_cache_headers( $response, $server, $request ) {
248 + if ( ! \str_starts_with( $request->get_route(), '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
249 + return $response;
250 + }
251 +
252 + /*
253 + * A request authenticated by WP session can get a personalized response even on a route whose
254 + * permission callback is `__return_true`: `/interactions` redirects by the current user, and
255 + * verify_owner() surfaces private items in otherwise public collections. The session cookie is
256 + * not part of Vary, so mark any logged-in response private before the public shortcut below.
102 257 */
103 - $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
258 + if ( \is_user_logged_in() ) {
259 + maybe_set_no_store( $response );
104 260
105 - if ( $defer ) {
106 261 return $response;
107 262 }
108 263
109 - // POST-Requets are always signed
110 - if ( 'GET' !== $request->get_method() ) {
111 - $verified_request = Signature::verify_http_signature( $request );
112 - if ( \is_wp_error( $verified_request ) ) {
113 - return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) );
114 - }
115 - } elseif ( 'GET' === $request->get_method() ) { // GET-Requests are only signed in secure mode
116 - if ( ACTIVITYPUB_AUTHORIZED_FETCH ) {
117 - $verified_request = Signature::verify_http_signature( $request );
118 - if ( \is_wp_error( $verified_request ) ) {
119 - return new WP_Error( 'activitypub_signature_verification', $verified_request->get_error_message(), array( 'status' => 401 ) );
264 + /*
265 + * A route that authorizes every caller identically (permission_callback `__return_true`) returns
266 + * a public response, even under Authorized Fetch, where Mastodon still signs its GETs. Leave it
267 + * without caller-varying cache directives so public collections such as thread replies and
268 + * context stay cacheable at the edge. Routes that gate on the caller fall through below.
269 + */
270 + $attributes = $request->get_attributes();
271 + if ( isset( $attributes['permission_callback'] ) && '__return_true' === $attributes['permission_callback'] ) {
272 + return $response;
273 + }
274 +
275 + $authorized_fetch = use_authorized_fetch();
276 +
277 + /*
278 + * Authorization always affects the response: the ActivityPub API varies it by access token.
279 + * The HTTP-signature headers only affect it under Authorized Fetch; with it off the response
280 + * is identical for every caller, and Mastodon sends a fresh Signature-Input on each GET, so
281 + * varying on them would mint a unique cache variant per request and defeat shared caching.
282 + */
283 + $vary = array( 'Authorization' );
284 +
285 + if ( $authorized_fetch ) {
286 + $vary[] = 'Signature';
287 + $vary[] = 'Signature-Input';
288 + }
289 +
290 + // Appended, so the CORS handler's own `Vary: Origin` survives.
291 + $response->header( 'Vary', \implode( ', ', $vary ), false );
292 +
293 + /*
294 + * Only mark a credentialed response private when Authorized Fetch actually varies it. With
295 + * Authorized Fetch off, a signed request gets the same public response as everyone else
296 + * (Mastodon signs its GETs by default), so `no-store` would needlessly drop it from every
297 + * CDN. The `Vary: Authorization` above still keeps a token-credentialed response from being reused.
298 + */
299 + if ( $authorized_fetch ) {
300 + $credentials = array( 'authorization', 'signature', 'signature-input' );
301 +
302 + foreach ( $credentials as $header ) {
303 + if ( $request->get_header( $header ) ) {
304 + maybe_set_no_store( $response );
305 + break;
120 306 }
121 307 }
122 308 }
123 309
124 310 return $response;
311 + }
312 +
313 + /**
314 + * Add CORS headers to ActivityPub REST responses.
315 + *
316 + * @param \WP_REST_Response $response The REST response.
317 + * @param \WP_REST_Server $server The REST server instance.
318 + * @param \WP_REST_Request $request The request object.
319 + *
320 + * @return \WP_REST_Response The modified response.
321 + */
322 + public static function add_cors_headers( $response, $server, $request ) {
323 + $route = $request->get_route();
324 + $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
325 +
326 + // Only add CORS to ActivityPub endpoints, except the interactive OAuth authorize endpoint.
327 + if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
328 + return $response;
329 + }
330 +
331 + /*
332 + * ActivityPub data is meant to be publicly readable by federation peers
333 + * and browser-side clients. We do not enable credentialed cross-origin
334 + * access: cookie auth would still be rejected by WordPress core's
335 + * REST nonce check, and OAuth Bearer tokens travel in the
336 + * Authorization header — which is permitted via Allow-Headers and
337 + * does not require Allow-Credentials.
338 + *
339 + * Allow-Headers is contributed by core (which already lists `X-WP-Nonce`,
340 + * `Authorization`, `Content-Type`, `Content-Disposition`, and `Content-MD5`)
341 + * and extended for ActivityPub via the `rest_allowed_cors_headers` filter
342 + * in self::allow_cors_headers().
343 + */
344 + $response->header( 'Access-Control-Allow-Origin', '*' );
345 + $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' );
346 +
347 + return $response;
348 + }
349 +
350 + /**
351 + * Extend the CORS Allow-Headers list for ActivityPub REST endpoints.
352 + *
353 + * Adds the headers ActivityPub clients need on top of WordPress core's
354 + * defaults: `Accept` for content negotiation and `Last-Event-ID` for
355 + * Server-Sent Events resume.
356 + *
357 + * @since 8.3.0
358 + *
359 + * @param string[] $allow_headers Headers core currently permits in CORS requests.
360 + * @param \WP_REST_Request $request The current REST request.
361 + *
362 + * @return string[] The (possibly extended) list of allowed headers.
363 + */
364 + public static function allow_cors_headers( $allow_headers, $request ) {
365 + $route = $request->get_route();
366 + $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
367 +
368 + if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
369 + return $allow_headers;
370 + }
371 +
372 + return \array_values( \array_unique( \array_merge( (array) $allow_headers, array( 'Accept', 'Last-Event-ID' ) ) ) );
373 + }
374 +
375 + /**
376 + * Send CORS headers directly via header().
377 + *
378 + * Use this for endpoints that bypass the REST response flow
379 + * (e.g. SSE streams that call exit() instead of returning a WP_REST_Response).
380 + *
381 + * @since 8.1.0
382 + */
383 + public static function send_cors_headers() {
384 + \header( 'Access-Control-Allow-Origin: *' );
385 + \header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
386 + \header( 'Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type, Accept, Last-Event-ID' );
125 387 }
126 388 }