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

303 lines 10.2 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 /**
13 * ActivityPub Server REST-Class.
14 *
15 * @author Django Doucet
16 *
17 * @see https://www.w3.org/TR/activitypub/#security-verification
18 */
19 class Server {
20 /**
21 * Initialize the class, registering WordPress hooks.
22 */
23 public static function init() {
24 \add_filter( 'rest_pre_dispatch', array( self::class, 'maybe_add_actor_from_signature' ), 10, 3 );
25 \add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
26 \add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
27
28 \add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 );
29 \add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 );
30 \add_filter( 'rest_allowed_cors_headers', array( self::class, 'allow_cors_headers' ), 10, 2 );
31 }
32
33 /**
34 * Callback function to validate incoming ActivityPub requests
35 *
36 * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Result to send to the client.
37 * Usually a WP_REST_Response or WP_Error.
38 * @param array $handler Route handler used for the request.
39 * @param \WP_REST_Request $request Request used to generate the response.
40 *
41 * @return mixed|\WP_Error The response, error, or modified response.
42 */
43 public static function validate_requests( $response, $handler, $request ) {
44 if ( 'HEAD' === $request->get_method() ) {
45 return $response;
46 }
47
48 $route = $request->get_route();
49
50 if (
51 \is_wp_error( $response ) ||
52 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE )
53 ) {
54 return $response;
55 }
56
57 $params = $request->get_json_params();
58
59 // Type is required for ActivityPub requests, so it fail later in the process.
60 if ( ! isset( $params['type'] ) ) {
61 return $response;
62 }
63
64 if (
65 ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
66 in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
67 ) {
68 return new \WP_Error(
69 'activitypub_server_does_not_accept_incoming_interactions',
70 \__( 'This server does not accept incoming interactions.', 'activitypub' ),
71 // We have to use a 2XX status code here, because otherwise the response will be
72 // treated as an error and Mastodon might block this WordPress instance.
73 array( 'status' => 202 )
74 );
75 }
76
77 return $response;
78 }
79
80 /**
81 * Modify the parameter priority order for a REST API request.
82 *
83 * @param string[] $order Array of types to check, in order of priority.
84 * @param \WP_REST_Request $request The request object.
85 *
86 * @return string[] The modified order of types to check.
87 */
88 public static function request_parameter_order( $order, $request ) {
89 $route = $request->get_route();
90
91 // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
92 if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
93 return $order;
94 }
95
96 $method = $request->get_method();
97
98 if ( \WP_REST_Server::CREATABLE !== $method ) {
99 return $order;
100 }
101
102 return array(
103 'JSON',
104 'POST',
105 'URL',
106 'defaults',
107 );
108 }
109
110 /**
111 * Backfill a missing `actor` on incoming FeatureRequest activities from the signature.
112 *
113 * Mastodon (FEP-7aa9) omits `actor` from the FeatureRequest body and conveys the
114 * requesting actor only through the HTTP signature keyId. Our inbox routes require
115 * `actor`, so such a request is rejected during parameter validation before it can
116 * reach the inbox or its handler, which is why no Accept is ever sent.
117 *
118 * Derive the actor from the keyId and add it as a request parameter. The actor is
119 * injected with `set_param()` rather than by rewriting the request body, so the raw
120 * body stays byte-identical and the signed `Digest` still verifies. Inbox POSTs read
121 * JSON parameters first (see `request_parameter_order()`), so the value is visible to
122 * both parameter validation and the handler via `get_json_params()`.
123 *
124 * Scoped to FeatureRequest, the only activity type known to address this way. Runs on
125 * `rest_pre_dispatch` because that is the only hook that fires before required-parameter
126 * validation. Signature verification still runs afterwards and remains authoritative:
127 * the injected actor is derived from the very keyId the signature is checked against, so
128 * it cannot be used to impersonate another actor.
129 *
130 * @since 9.0.0
131 *
132 * @param mixed $result Response to replace the request with, or null to continue.
133 * @param \WP_REST_Server $server Server instance.
134 * @param \WP_REST_Request $request The request object.
135 *
136 * @return mixed The unmodified `$result`.
137 */
138 public static function maybe_add_actor_from_signature( $result, $server, $request ) {
139 // Respect an earlier short-circuit.
140 if ( null !== $result ) {
141 return $result;
142 }
143
144 if ( \WP_REST_Server::CREATABLE !== $request->get_method() ) {
145 return $result;
146 }
147
148 $route = $request->get_route();
149 if (
150 ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ||
151 ! \str_ends_with( $route, '/inbox' )
152 ) {
153 return $result;
154 }
155
156 $json = $request->get_json_params();
157 if ( ! \is_array( $json ) || 'FeatureRequest' !== ( $json['type'] ?? '' ) || ! empty( $json['actor'] ) ) {
158 return $result;
159 }
160
161 $key_id = Signature::get_key_id( $request );
162 if ( ! $key_id ) {
163 return $result;
164 }
165
166 $request->set_param( 'actor', \strip_fragment_from_url( $key_id ) );
167
168 return $result;
169 }
170
171 /**
172 * Filters the REST API response to properly handle the ActivityPub error formatting.
173 *
174 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/c180/fep-c180.md
175 *
176 * @param \WP_HTTP_Response $response Result to send to the client. Usually a `WP_REST_Response`.
177 * @param \WP_REST_Server $server Server instance.
178 * @param \WP_REST_Request $request Request used to generate the response.
179 *
180 * @return \WP_HTTP_Response The filtered response.
181 */
182 public static function filter_output( $response, $server, $request ) {
183 $route = $request->get_route();
184
185 // Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
186 if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
187 return $response;
188 }
189
190 // Exclude OAuth endpoints - they have their own error format per RFC 6749.
191 if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) {
192 return $response;
193 }
194
195 // Only alter responses that return an error status code.
196 if ( $response->get_status() < 400 ) {
197 return $response;
198 }
199
200 $data = $response->get_data();
201
202 // Ensure that `$data` was already converted to a response.
203 if ( \is_wp_error( $data ) ) {
204 $response = \rest_convert_error_to_response( $data );
205 $data = $response->get_data();
206 }
207
208 $error = array(
209 'type' => 'about:blank',
210 'title' => $data['code'] ?? '',
211 'detail' => $data['message'] ?? '',
212 'status' => $response->get_status(),
213
214 /*
215 * Provides the unstructured error data.
216 *
217 * @see https://nodeinfo.diaspora.software/schema.html#metadata.
218 */
219 'metadata' => $data,
220 );
221
222 $response->set_data( $error );
223
224 return $response;
225 }
226
227 /**
228 * Add CORS headers to ActivityPub REST responses.
229 *
230 * @param \WP_REST_Response $response The REST response.
231 * @param \WP_REST_Server $server The REST server instance.
232 * @param \WP_REST_Request $request The request object.
233 *
234 * @return \WP_REST_Response The modified response.
235 */
236 public static function add_cors_headers( $response, $server, $request ) {
237 $route = $request->get_route();
238 $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
239
240 // Only add CORS to ActivityPub endpoints, except the interactive OAuth authorize endpoint.
241 if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
242 return $response;
243 }
244
245 /*
246 * ActivityPub data is meant to be publicly readable by federation peers
247 * and browser-side clients. We do not enable credentialed cross-origin
248 * access: cookie auth would still be rejected by WordPress core's
249 * REST nonce check, and OAuth Bearer tokens travel in the
250 * Authorization header — which is permitted via Allow-Headers and
251 * does not require Allow-Credentials.
252 *
253 * Allow-Headers is contributed by core (which already lists `X-WP-Nonce`,
254 * `Authorization`, `Content-Type`, `Content-Disposition`, and `Content-MD5`)
255 * and extended for ActivityPub via the `rest_allowed_cors_headers` filter
256 * in self::allow_cors_headers().
257 */
258 $response->header( 'Access-Control-Allow-Origin', '*' );
259 $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' );
260
261 return $response;
262 }
263
264 /**
265 * Extend the CORS Allow-Headers list for ActivityPub REST endpoints.
266 *
267 * Adds the headers ActivityPub clients need on top of WordPress core's
268 * defaults: `Accept` for content negotiation and `Last-Event-ID` for
269 * Server-Sent Events resume.
270 *
271 * @since 8.3.0
272 *
273 * @param string[] $allow_headers Headers core currently permits in CORS requests.
274 * @param \WP_REST_Request $request The current REST request.
275 *
276 * @return string[] The (possibly extended) list of allowed headers.
277 */
278 public static function allow_cors_headers( $allow_headers, $request ) {
279 $route = $request->get_route();
280 $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
281
282 if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
283 return $allow_headers;
284 }
285
286 return \array_values( \array_unique( \array_merge( (array) $allow_headers, array( 'Accept', 'Last-Event-ID' ) ) ) );
287 }
288
289 /**
290 * Send CORS headers directly via header().
291 *
292 * Use this for endpoints that bypass the REST response flow
293 * (e.g. SSE streams that call exit() instead of returning a WP_REST_Response).
294 *
295 * @since 8.1.0
296 */
297 public static function send_cors_headers() {
298 \header( 'Access-Control-Allow-Origin: *' );
299 \header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
300 \header( 'Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type, Accept, Last-Event-ID' );
301 }
302 }
303