PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.0
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 9.2.0, at includes/rest/class-server.php

389 lines 13.6 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, '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 );
30
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 );
35 }
36
37 /**
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.
46 */
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;
82 }
83
84 /**
85 * Modify the parameter priority order for a REST API request.
86 *
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.
91 */
92 public static function request_parameter_order( $order, $request ) {
93 $route = $request->get_route();
94
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 }
99
100 $method = $request->get_method();
101
102 if ( \WP_REST_Server::CREATABLE !== $method ) {
103 return $order;
104 }
105
106 return array(
107 'JSON',
108 'POST',
109 'URL',
110 'defaults',
111 );
112 }
113
114 /**
115 * Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
116 *
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.
121 *
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()`.
127 *
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`.
141 */
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;
146 }
147
148 if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
149 return $result;
150 }
151
152 $route = $request->get_route();
153 if (
154 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
155 ! \str_ends_with( $route, '/inbox' )
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 ) ) {
191 return $response;
192 }
193
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.
257 */
258 if ( \is_user_logged_in() ) {
259 maybe_set_no_store( $response );
260
261 return $response;
262 }
263
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;
306 }
307 }
308 }
309
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' );
387 }
388 }
389