PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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
activitypub / includes / rest / class-server.php

class-server.php in ActivityPub trunk, at includes/rest/class-server.php

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