PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.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
← All changes | includes/rest/class-server.php +39 -256 9.3.07.7.0 View file →
@@ -8,9 +8,8 @@
8 8 namespace Activitypub\Rest;
9 9
10 10 use Activitypub\Signature;
11 11
12 -use function Activitypub\maybe_set_no_store;
13 12 use function Activitypub\use_authorized_fetch;
14 13
15 14 /**
16 15 * ActivityPub Server REST-Class.
@@ -23,57 +22,65 @@
23 22 /**
24 23 * Initialize the class, registering WordPress hooks.
25 24 */
26 25 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 26 \add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
30 27 \add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
31 28
32 29 \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 30 }
37 31
38 32 /**
39 - * Normalize the route of an ActivityPub request before it is matched to a handler.
33 + * Callback function to authorize an api request.
40 34 *
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.
35 + * The function is meant to be used as part of permission callbacks for rest api endpoints.
47 36 *
48 - * Routes outside the ActivityPub namespace are left alone: their segments belong to other endpoints,
49 - * which may well treat them as case-sensitive.
37 + * It verifies the signature of POST, PUT, PATCH, and DELETE requests, as well as GET requests in secure mode.
38 + * You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification.
39 + * HEAD requests are always bypassed.
50 40 *
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.
41 + * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
42 + * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
54 43 *
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 44 * @param \WP_REST_Request $request The request object.
60 45 *
61 - * @return mixed The unmodified `$result`.
46 + * @return bool|\WP_Error True if the request is authorized, WP_Error if not.
62 47 */
63 - public static function normalize_route( $result, $server, $request ) {
64 - // Respect an earlier short-circuit.
65 - if ( null !== $result ) {
66 - return $result;
48 + public static function verify_signature( $request ) {
49 + if ( 'HEAD' === $request->get_method() ) {
50 + return true;
67 51 }
68 52
69 - $route = \strtolower( $request->get_route() );
53 + /**
54 + * Filter to defer signature verification.
55 + *
56 + * Skip signature verification for debugging purposes or to reduce load for
57 + * certain Activity-Types, like "Delete".
58 + *
59 + * @param bool $defer Whether to defer signature verification.
60 + * @param \WP_REST_Request $request The request used to generate the response.
61 + *
62 + * @return bool Whether to defer signature verification.
63 + */
64 + $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
70 65
71 - if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
72 - $request->set_route( $route );
66 + if ( $defer ) {
67 + return true;
73 68 }
74 69
75 - return $result;
70 + // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode.
71 + if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) {
72 + $verified_request = Signature::verify_http_signature( $request );
73 + if ( \is_wp_error( $verified_request ) ) {
74 + return new \WP_Error(
75 + 'activitypub_signature_verification',
76 + $verified_request->get_error_message(),
77 + array( 'status' => 401 )
78 + );
79 + }
80 + }
81 +
82 + return true;
76 83 }
77 84
78 85 /**
79 86 * Callback function to validate incoming ActivityPub requests
@@ -107,9 +114,9 @@
107 114 }
108 115
109 116 if (
110 117 ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
111 - \in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
118 + in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
112 119 ) {
113 120 return new \WP_Error(
114 121 'activitypub_server_does_not_accept_incoming_interactions',
115 122 \__( 'This server does not accept incoming interactions.', 'activitypub' ),
@@ -152,69 +159,8 @@
152 159 );
153 160 }
154 161
155 162 /**
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 163 * Filters the REST API response to properly handle the ActivityPub error formatting.
218 164 *
219 165 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/c180/fep-c180.md
220 166 *
@@ -231,13 +177,8 @@
231 177 if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
232 178 return $response;
233 179 }
234 180
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 181 // Only alter responses that return an error status code.
241 182 if ( $response->get_status() < 400 ) {
242 183 return $response;
243 184 }
@@ -266,164 +207,6 @@
266 207
267 208 $response->set_data( $error );
268 209
269 210 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 211 }
429 212 }