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 / class-http.php

class-http.php in ActivityPub trunk, at includes/class-http.php

449 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ActivityPub HTTP Class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11
12 /**
13 * ActivityPub HTTP Class
14 *
15 * @author Matthias Pfefferle
16 */
17 class Http {
18 /**
19 * Send a POST Request with the needed HTTP Headers
20 *
21 * @param string $url The URL endpoint.
22 * @param string $body The Post Body.
23 * @param int|null $user_id The WordPress User-ID, or null to sign with the Application key.
24 *
25 * @return array|\WP_Error The POST Response or an WP_Error.
26 */
27 public static function post( $url, $body, $user_id ) {
28 /**
29 * Fires before an HTTP POST request is made.
30 *
31 * @param string $url The URL endpoint.
32 * @param string $body The POST body.
33 * @param int|null $user_id The WordPress User ID, or null when signing with the Application key.
34 */
35 \do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
36
37 /**
38 * Filters the HTTP headers user agent string.
39 *
40 * @param string $user_agent The user agent string.
41 * @param string $url The request URL.
42 */
43 $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ), $url );
44
45 /**
46 * Filters the timeout duration for remote POST requests in ActivityPub.
47 *
48 * @param int $timeout The timeout value in seconds. Default 10 seconds.
49 */
50 $timeout = \apply_filters( 'activitypub_remote_post_timeout', 10 );
51
52 /*
53 * Get the private key for signing the request. If a user ID is provided,
54 * get the user's private key; otherwise, use the application's private key.
55 */
56 $private_key = null === $user_id ? Application::get_private_key() : Actors::get_private_key( $user_id );
57
58 $args = array(
59 'timeout' => $timeout,
60 'limit_response_size' => 1048576,
61 'redirection' => 3,
62 'user-agent' => "$user_agent; ActivityPub",
63 'headers' => array(
64 'Accept' => 'application/activity+json',
65 'Content-Type' => 'application/activity+json',
66 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
67 ),
68 'body' => $body,
69 'key_id' => \json_decode( $body )->actor . '#main-key',
70 'private_key' => $private_key,
71 'user_id' => $user_id,
72 );
73
74 $response = \wp_safe_remote_post( $url, $args );
75 $code = \wp_remote_retrieve_response_code( $response );
76
77 if ( $code >= 400 ) {
78 $response = new \WP_Error(
79 $code,
80 \__( 'Failed HTTP Request', 'activitypub' ),
81 array(
82 'status' => $code,
83 'response' => $response,
84 )
85 );
86 }
87
88 /**
89 * Action to save the response of the remote POST request.
90 *
91 * @param array|\WP_Error $response The response of the remote POST request.
92 * @param string $url The URL endpoint.
93 * @param string $body The Post Body.
94 * @param int|null $user_id The WordPress User-ID, or null when signing with the Application key.
95 */
96 \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
97
98 return $response;
99 }
100
101 /**
102 * Send a GET Request with the needed HTTP Headers.
103 *
104 * @param string $url The URL endpoint.
105 * @param array $args Optional. Additional arguments to customize the request.
106 * - 'headers': Array of headers to override defaults.
107 * @param bool|int $cached Optional. Whether to cache the response, or the cache duration in seconds for
108 * successful responses. Failed responses use a fixed short backoff duration. Default false.
109 *
110 * @return array|\WP_Error The GET Response or a WP_Error.
111 */
112 public static function get( $url, $args = array(), $cached = false ) {
113 // Backward compatibility: if $args is boolean/int, it's the old $cached parameter.
114 if ( ! \is_array( $args ) ) {
115 \_deprecated_argument(
116 __METHOD__,
117 '7.9.0',
118 \esc_html__( 'The $cached parameter should now be passed as the third argument.', 'activitypub' )
119 );
120 $cached = $args;
121 $args = array();
122 }
123
124 /**
125 * Fires before an HTTP GET request is made.
126 *
127 * @param string $url The URL endpoint.
128 */
129 \do_action( 'activitypub_pre_http_get', $url );
130
131 $transient_key = self::generate_cache_key( $url );
132
133 // Check cache only if caching is requested.
134 if ( $cached ) {
135 $response = \get_transient( $transient_key );
136
137 if ( $response ) {
138 /**
139 * Action to save the response of the remote GET request.
140 *
141 * @param array|\WP_Error $response The response of the remote GET request.
142 * @param string $url The URL endpoint.
143 */
144 \do_action( 'activitypub_safe_remote_get_response', $response, $url );
145
146 return $response;
147 }
148 }
149
150 /**
151 * Filters the HTTP headers user agent string.
152 *
153 * @param string $user_agent The user agent string.
154 * @param string $url The request URL.
155 */
156 $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ), $url );
157
158 /**
159 * Filters the timeout duration for remote GET requests in ActivityPub.
160 *
161 * @param int $timeout The timeout value in seconds. Default 10 seconds.
162 */
163 $timeout = \apply_filters( 'activitypub_remote_get_timeout', 10 );
164
165 $defaults = array(
166 'timeout' => $timeout,
167 'limit_response_size' => 1048576,
168 'redirection' => 3,
169 'user-agent' => "$user_agent; ActivityPub",
170 'headers' => array(
171 'Accept' => 'application/activity+json',
172 'Content-Type' => 'application/activity+json',
173 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
174 ),
175 'key_id' => Application::get_key_id(),
176 'private_key' => Application::get_private_key(),
177 );
178
179 $args = \wp_parse_args( $args, $defaults );
180 $args['headers'] = \wp_parse_args( $args['headers'], $defaults['headers'] );
181
182 $response = \wp_safe_remote_get( $url, $args );
183 $code = \wp_remote_retrieve_response_code( $response );
184
185 if ( \is_wp_error( $response ) || $code >= 400 ) {
186 // Capture the served-from URL before $response is replaced by the error below.
187 $effective_url = \is_wp_error( $response ) ? '' : self::effective_url( $response );
188
189 if ( ! $code ) {
190 $code = 0;
191 }
192 $response = new \WP_Error( $code, \__( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
193
194 /*
195 * Cache errors to prevent repeated timeout waits, but never one reached via a
196 * cross-host redirect: cached under the requested URL's key, a one-off open
197 * redirect on the requested host would let a transient 4xx be replayed as a
198 * repeatable federation outage (the same caching concern as the success path
199 * below, just with a WP_Error instead of a document).
200 *
201 * - Retriable errors (timeouts, 5xx): 1 minute (server may recover quickly).
202 * - Other errors (4xx): 15 minutes (client errors are more permanent).
203 */
204 if ( $cached && ( ! $effective_url || is_same_host( $url, $effective_url ) ) ) {
205 if ( \in_array( $code, ACTIVITYPUB_RETRY_ERROR_CODES, true ) || 0 === $code ) {
206 $cache_duration = MINUTE_IN_SECONDS;
207 } else {
208 $cache_duration = 15 * MINUTE_IN_SECONDS;
209 }
210
211 \set_transient( $transient_key, $response, $cache_duration );
212 }
213
214 return $response;
215 }
216
217 /**
218 * Action to save the response of the remote GET request.
219 *
220 * @param array|\WP_Error $response The response of the remote GET request.
221 * @param string $url The URL endpoint.
222 */
223 \do_action( 'activitypub_safe_remote_get_response', $response, $url );
224
225 /*
226 * Never persist a response that was redirected to a different host. The cache
227 * is keyed on the requested URL, so caching cross-origin content under that key
228 * would let a one-off open redirect on the requested host durably associate the
229 * other host's document with that URL — a later lookup (e.g. a public-key fetch)
230 * would then return it. Same-host redirects (e.g. http→https) stay cacheable.
231 */
232 $effective_url = self::effective_url( $response );
233 if ( $effective_url && ! is_same_host( $url, $effective_url ) ) {
234 return $response;
235 }
236
237 // Cache successful responses when caching is requested.
238 if ( $cached ) {
239 $cache_duration = $cached;
240 if ( ! \is_int( $cache_duration ) ) {
241 $cache_duration = HOUR_IN_SECONDS;
242 }
243 \set_transient( $transient_key, $response, $cache_duration );
244 }
245
246 return $response;
247 }
248
249 /**
250 * Check for URL for Tombstone.
251 *
252 * @param string $url The URL to check.
253 *
254 * @return bool True if the URL is a tombstone.
255 */
256 public static function is_tombstone( $url ) {
257 \_deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
258
259 return Tombstone::exists_remote( $url );
260 }
261
262 /**
263 * Generate a cache key for the URL.
264 *
265 * @param string $url The URL to generate the cache key for.
266 *
267 * @return string The cache key.
268 */
269 public static function generate_cache_key( $url ) {
270 return 'activitypub_http_' . \md5( $url );
271 }
272
273 /**
274 * Requests the Data from the Object-URL or Object-Array.
275 *
276 * Fetched objects are self-confirmed before they are returned, the same way
277 * Mastodon's `JsonLdHelper#fetch_resource` works: an object is trusted only when
278 * its own `id` is the URL it was actually served from (after any redirects). If
279 * the document served at the requested URL declares a different `id`, that id is
280 * dereferenced from its own host and accepted only when it self-confirms. This
281 * makes every caller safe to cache the result under its `id` — one host can never
282 * serve an object (and its public key) under another host's id — without each
283 * caller having to re-check the origin itself.
284 *
285 * @param array|string $url_or_object The Object or the Object URL.
286 * @param bool $cached Optional. Whether the result should be cached. Default true.
287 *
288 * @return array|\WP_Error The Object data as array or WP_Error on failure.
289 */
290 public static function get_remote_object( $url_or_object, $cached = true ) {
291 /**
292 * Filters the preemptive return value of a remote object request.
293 *
294 * This is an explicit in-process override (used for caching and tests), not
295 * untrusted network data, so it is returned as-is without self-confirmation.
296 *
297 * @param array|string|null $response The response.
298 * @param array|string|null $url_or_object The Object or the Object URL.
299 */
300 $response = \apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
301 if ( null !== $response ) {
302 return $response;
303 }
304
305 $url = object_to_uri( $url_or_object );
306
307 if ( Webfinger::is_acct( $url ) ) {
308 $url = Webfinger::resolve( $url );
309 }
310
311 if ( ! $url ) {
312 return new \WP_Error(
313 'activitypub_no_valid_actor_identifier',
314 \__( 'The "actor" identifier is not valid', 'activitypub' ),
315 array(
316 'status' => 404,
317 'object' => $url,
318 )
319 );
320 }
321
322 if ( \is_wp_error( $url ) ) {
323 return $url;
324 }
325
326 $final_url = '';
327 $object = self::fetch_object( $url, $cached, $final_url );
328
329 if ( \is_wp_error( $object ) ) {
330 return $object;
331 }
332
333 // Trust the document when it is served under its own id (after redirects).
334 if ( id_matches_url( $object, $final_url ) ) {
335 return $object;
336 }
337
338 $declared_id = isset( $object['id'] ) && \is_string( $object['id'] ) ? $object['id'] : '';
339
340 /*
341 * An id-less object cannot be cached under an id, so it cannot be written
342 * under another id in an id-keyed cache. Return the document as served.
343 */
344 if ( '' === $declared_id ) {
345 return $object;
346 }
347
348 // Re-fetch the declared id from its own host and require it to self-confirm. One hop only.
349 $object = self::fetch_object( $declared_id, $cached, $final_url );
350
351 if ( \is_wp_error( $object ) ) {
352 return $object;
353 }
354
355 if ( ! id_matches_url( $object, $final_url ) ) {
356 return new \WP_Error(
357 'activitypub_object_id_mismatch',
358 \__( 'The object id does not match the URL it was served from', 'activitypub' ),
359 array( 'status' => 400 )
360 );
361 }
362
363 return $object;
364 }
365
366 /**
367 * Fetch and JSON-decode a single remote document.
368 *
369 * @param string $url The URL to fetch. Must already be resolved (not a WebFinger acct).
370 * @param bool $cached Whether the result may be served from and written to cache.
371 * @param string $final_url Filled by reference with the URL the document was served from,
372 * after following any redirects.
373 *
374 * @return array|\WP_Error The decoded document, or WP_Error on failure.
375 */
376 private static function fetch_object( $url, $cached, &$final_url ) {
377 $final_url = $url;
378
379 if ( ! \wp_http_validate_url( $url ) ) {
380 return new \WP_Error(
381 'activitypub_no_valid_object_url',
382 \__( 'The "object" is/has no valid URL', 'activitypub' ),
383 array(
384 'status' => 400,
385 'object' => $url,
386 )
387 );
388 }
389
390 $response = self::get( $url, array(), $cached );
391
392 if ( \is_wp_error( $response ) ) {
393 return $response;
394 }
395
396 $effective_url = self::effective_url( $response );
397 if ( $effective_url ) {
398 $final_url = $effective_url;
399 }
400
401 $data = \json_decode( \wp_remote_retrieve_body( $response ), true );
402
403 if ( ! $data ) {
404 return new \WP_Error(
405 'activitypub_invalid_json',
406 \__( 'No valid JSON data', 'activitypub' ),
407 array(
408 'status' => 400,
409 'object' => $url,
410 )
411 );
412 }
413
414 return $data;
415 }
416
417 /**
418 * Extract the effective URL a response was served from, after redirects.
419 *
420 * WordPress follows redirects transparently and exposes the final URL only on
421 * the underlying Requests response object. Returns an empty string when the URL
422 * cannot be determined, so callers fall back to the URL they requested.
423 *
424 * SECURITY: the redirect protections that build on this (the self-confirmation in
425 * get_remote_object() and the cross-host cache skip in get()) fail OPEN when this
426 * returns an empty string — self-confirmation then compares against the requested
427 * URL, which a redirect could have bounced away from. This relies on the internal
428 * `http_response` → Requests response `url` shape; if a future WordPress release
429 * changes it, re-verify that this still returns the post-redirect URL.
430 *
431 * @param array $response A `wp_remote_get()` response array.
432 *
433 * @return string The final URL, or an empty string when unavailable.
434 */
435 private static function effective_url( $response ) {
436 if ( empty( $response['http_response'] ) || ! \is_object( $response['http_response'] ) ) {
437 return '';
438 }
439
440 $requests_response = $response['http_response']->get_response_object();
441
442 if ( ! \is_object( $requests_response ) || empty( $requests_response->url ) ) {
443 return '';
444 }
445
446 return (string) $requests_response->url;
447 }
448 }
449