PluginProbe
ActivityPub / 3.2.4
ActivityPub v3.2.4
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 +104 -313 9.2.23.2.4 View file →
@@ -1,20 +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 -use function Activitypub\maybe_set_no_store;
13 -use function Activitypub\use_authorized_fetch;
14 -
15 10 /**
16 - * ActivityPub Server REST-Class.
11 + * ActivityPub Server REST-Class
17 12 *
18 13 * @author Django Doucet
19 14 *
20 15 * @see https://www.w3.org/TR/activitypub/#security-verification
@@ -20,369 +15,165 @@
20 15 * @see https://www.w3.org/TR/activitypub/#security-verification
21 16 */
22 17 class Server {
23 18 /**
24 - * Initialize the class, registering WordPress hooks.
19 + * Initialize the class, registering WordPress hooks
25 20 */
26 21 public static function init() {
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 );
22 + self::register_routes();
30 23
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 );
24 + \add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_activitypub_requests' ), 9, 3 );
25 + \add_filter( 'rest_request_before_callbacks', array( self::class, 'authorize_activitypub_requests' ), 10, 3 );
35 26 }
36 27
37 28 /**
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 + * Register routes
46 30 */
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;
31 + public static function register_routes() {
32 + \register_rest_route(
33 + ACTIVITYPUB_REST_NAMESPACE,
34 + '/application',
35 + array(
36 + array(
37 + 'methods' => \WP_REST_Server::READABLE,
38 + 'callback' => array( self::class, 'application_actor' ),
39 + 'permission_callback' => '__return_true',
40 + ),
41 + )
42 + );
82 43 }
83 44
84 45 /**
85 - * Modify the parameter priority order for a REST API request.
46 + * Render Application actor profile
86 47 *
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 + * @return WP_REST_Response The JSON profile of the Application Actor.
91 49 */
92 - public static function request_parameter_order( $order, $request ) {
93 - $route = $request->get_route();
50 + public static function application_actor() {
51 + $user = new Application();
94 52
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 - }
53 + $json = $user->to_array();
99 54
100 - $method = $request->get_method();
55 + $rest_response = new WP_REST_Response( $json, 200 );
56 + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) );
101 57
102 - if ( \WP_REST_Server::CREATABLE !== $method ) {
103 - return $order;
104 - }
105 -
106 - return array(
107 - 'JSON',
108 - 'POST',
109 - 'URL',
110 - 'defaults',
111 - );
58 + return $rest_response;
112 59 }
113 60
114 61 /**
115 - * Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
62 + * Callback function to authorize each api requests
116 63 *
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.
64 + * @see WP_REST_Request
121 65 *
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()`.
66 + * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
67 + * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
127 68 *
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.
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.
133 73 *
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`.
74 + * @return mixed|WP_Error The response, error, or modified response.
141 75 */
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;
76 + public static function authorize_activitypub_requests( $response, $handler, $request ) {
77 + if ( 'HEAD' === $request->get_method() ) {
78 + return $response;
146 79 }
147 80
148 - if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
149 - return $result;
81 + if ( \is_wp_error( $response ) ) {
82 + return $response;
150 83 }
151 84
152 85 $route = $request->get_route();
86 +
87 + // check if it is an activitypub request and exclude webfinger and nodeinfo endpoints
153 88 if (
154 89 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
155 - ! \str_ends_with( $route, '/inbox' )
90 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'webfinger' ) ||
91 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'nodeinfo' ) ||
92 + \str_starts_with( $route, '/' . \trailingslashit( ACTIVITYPUB_REST_NAMESPACE ) . 'application' )
156 93 ) {
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 ) ) {
191 94 return $response;
192 95 }
193 96
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 - }
97 + /**
98 + * Filter to defer signature verification
99 + *
100 + * Skip signature verification for debugging purposes or to reduce load for
101 + * certain Activity-Types, like "Delete".
102 + *
103 + * @param bool $defer Whether to defer signature verification.
104 + * @param WP_REST_Request $request The request used to generate the response.
105 + *
106 + * @return bool Whether to defer signature verification.
107 + */
108 + $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
198 109
199 - // Only alter responses that return an error status code.
200 - if ( $response->get_status() < 400 ) {
110 + if ( $defer ) {
201 111 return $response;
202 112 }
203 113
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();
114 + if (
115 + // POST-Requests are always signed
116 + 'GET' !== $request->get_method() ||
117 + // GET-Requests only require a signature in secure mode
118 + ( 'GET' === $request->get_method() && ACTIVITYPUB_AUTHORIZED_FETCH )
119 + ) {
120 + $verified_request = Signature::verify_http_signature( $request );
121 + if ( \is_wp_error( $verified_request ) ) {
122 + return new WP_Error(
123 + 'activitypub_signature_verification',
124 + $verified_request->get_error_message(),
125 + array( 'status' => 401 )
126 + );
127 + }
210 128 }
211 129
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 130 return $response;
229 131 }
230 132
231 133 /**
232 - * Add cache headers to ActivityPub responses.
134 + * Callback function to validate incoming ActivityPub requests
233 135 *
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.
136 + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
137 + * Usually a WP_REST_Response or WP_Error.
138 + * @param array $handler Route handler used for the request.
139 + * @param WP_REST_Request $request Request used to generate the response.
238 140 *
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.
141 + * @return mixed|WP_Error The response, error, or modified response.
246 142 */
247 - public static function add_cache_headers( $response, $server, $request ) {
248 - if ( ! \str_starts_with( $request->get_route(), '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
143 + public static function validate_activitypub_requests( $response, $handler, $request ) {
144 + if ( 'HEAD' === $request->get_method() ) {
249 145 return $response;
250 146 }
251 147
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.
257 - */
258 - if ( \is_user_logged_in() ) {
259 - maybe_set_no_store( $response );
148 + $route = $request->get_route();
260 149
150 + if (
151 + \is_wp_error( $response ) ||
152 + ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE )
153 + ) {
261 154 return $response;
262 155 }
263 156
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'] ) {
157 + $params = $request->get_json_params();
158 +
159 + // Type is required for ActivityPub requests, so it fail later in the process
160 + if ( ! isset( $params['type'] ) ) {
272 161 return $response;
273 162 }
274 163
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';
164 + if (
165 + ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
166 + in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
167 + ) {
168 + return new WP_Error(
169 + 'activitypub_server_does_not_accept_incoming_interactions',
170 + \__( 'This server does not accept incoming interactions.', 'activitypub' ),
171 + // We have to use a 2XX status code here, because otherwise the response will be
172 + // treated as an error and Mastodon might block this WordPress instance.
173 + array( 'status' => 202 )
174 + );
288 175 }
289 176
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;
306 - }
307 - }
308 - }
309 -
310 177 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' );
387 178 }
388 179 }