PluginProbe
ActivityPub / 3.2.5
ActivityPub v3.2.5
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/class-http.php +97 -294 9.2.03.2.5 View file →
@@ -1,14 +1,11 @@
1 1 <?php
2 -/**
3 - * ActivityPub HTTP Class.
4 - *
5 - * @package Activitypub
6 - */
2 +namespace Activitypub;
7 3
8 -namespace Activitypub;
4 +use WP_Error;
5 +use Activitypub\Collection\Users;
9 6
10 -use Activitypub\Collection\Actors;
7 +use function Activitypub\get_masked_wp_version;
11 8
12 9 /**
13 10 * ActivityPub HTTP Class
14 11 *
@@ -17,59 +14,42 @@
17 14 class Http {
18 15 /**
19 16 * Send a POST Request with the needed HTTP Headers
20 17 *
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.
18 + * @param string $url The URL endpoint
19 + * @param string $body The Post Body
20 + * @param int $user_id The WordPress User-ID
24 21 *
25 - * @return array|\WP_Error The POST Response or an WP_Error.
22 + * @return array|WP_Error The POST Response or an WP_ERROR
26 23 */
27 24 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 25 \do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
36 26
27 + $date = \gmdate( 'D, d M Y H:i:s T' );
28 + $digest = Signature::generate_digest( $body );
29 + $signature = Signature::generate_signature( $user_id, 'post', $url, $date, $digest );
30 +
31 + $wp_version = get_masked_wp_version();
32 +
37 33 /**
38 - * Filters the HTTP headers user agent string.
34 + * Filter the HTTP headers user agent.
39 35 *
40 36 * @param string $user_agent The user agent string.
41 - * @param string $url The request URL.
42 37 */
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 -
38 + $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
58 39 $args = array(
59 - 'timeout' => $timeout,
40 + 'timeout' => 100,
60 41 'limit_response_size' => 1048576,
61 - 'redirection' => 3,
62 - 'user-agent' => "$user_agent; ActivityPub",
63 - 'headers' => array(
64 - 'Accept' => 'application/activity+json',
42 + 'redirection' => 3,
43 + 'user-agent' => "$user_agent; ActivityPub",
44 + 'headers' => array(
45 + 'Accept' => 'application/activity+json',
65 46 'Content-Type' => 'application/activity+json',
66 - 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
47 + 'Digest' => $digest,
48 + 'Signature' => $signature,
49 + 'Date' => $date,
67 50 ),
68 - 'body' => $body,
69 - 'key_id' => \json_decode( $body )->actor . '#main-key',
70 - 'private_key' => $private_key,
71 - 'user_id' => $user_id,
51 + 'body' => $body,
72 52 );
73 53
74 54 $response = \wp_safe_remote_post( $url, $args );
75 55 $code = \wp_remote_retrieve_response_code( $response );
@@ -74,26 +54,11 @@
74 54 $response = \wp_safe_remote_post( $url, $args );
75 55 $code = \wp_remote_retrieve_response_code( $response );
76 56
77 57 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 - );
58 + $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
86 59 }
87 60
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 61 \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
97 62
98 63 return $response;
99 64 }
@@ -98,50 +63,24 @@
98 63 return $response;
99 64 }
100 65
101 66 /**
102 - * Send a GET Request with the needed HTTP Headers.
67 + * Send a GET Request with the needed HTTP Headers
103 68 *
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.
69 + * @param string $url The URL endpoint
70 + * @param bool|int $cached If the result should be cached, or its duration. Default: 1hr.
109 71 *
110 - * @return array|\WP_Error The GET Response or a WP_Error.
72 + * @return array|WP_Error The GET Response or an WP_ERROR
111 73 */
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 - */
74 + public static function get( $url, $cached = false ) {
129 75 \do_action( 'activitypub_pre_http_get', $url );
130 76
131 - $transient_key = self::generate_cache_key( $url );
77 + if ( $cached ) {
78 + $transient_key = self::generate_cache_key( $url );
132 79
133 - // Check cache only if caching is requested.
134 - if ( $cached ) {
135 80 $response = \get_transient( $transient_key );
136 81
137 82 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 83 \do_action( 'activitypub_safe_remote_get_response', $response, $url );
145 84
146 85 return $response;
147 86 }
@@ -146,99 +85,45 @@
146 85 return $response;
147 86 }
148 87 }
149 88
89 + $date = \gmdate( 'D, d M Y H:i:s T' );
90 + $signature = Signature::generate_signature( Users::APPLICATION_USER_ID, 'get', $url, $date );
91 +
92 + $wp_version = get_masked_wp_version();
93 +
150 94 /**
151 - * Filters the HTTP headers user agent string.
95 + * Filter the HTTP headers user agent.
152 96 *
153 97 * @param string $user_agent The user agent string.
154 - * @param string $url The request URL.
155 98 */
156 - $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . get_masked_wp_version() . '; ' . \get_bloginfo( 'url' ), $url );
99 + $user_agent = \apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . \get_bloginfo( 'url' ) );
157 100
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,
101 + $args = array(
102 + 'timeout' => apply_filters( 'activitypub_remote_get_timeout', 100 ),
167 103 'limit_response_size' => 1048576,
168 - 'redirection' => 3,
169 - 'user-agent' => "$user_agent; ActivityPub",
170 - 'headers' => array(
171 - 'Accept' => 'application/activity+json',
104 + 'redirection' => 3,
105 + 'user-agent' => "$user_agent; ActivityPub",
106 + 'headers' => array(
107 + 'Accept' => 'application/activity+json',
172 108 'Content-Type' => 'application/activity+json',
173 - 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
109 + 'Signature' => $signature,
110 + 'Date' => $date,
174 111 ),
175 - 'key_id' => Application::get_key_id(),
176 - 'private_key' => Application::get_private_key(),
177 112 );
178 113
179 - $args = \wp_parse_args( $args, $defaults );
180 - $args['headers'] = \wp_parse_args( $args['headers'], $defaults['headers'] );
181 -
182 114 $response = \wp_safe_remote_get( $url, $args );
183 115 $code = \wp_remote_retrieve_response_code( $response );
184 116
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;
117 + if ( $code >= 400 ) {
118 + $response = new WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
215 119 }
216 120
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 121 \do_action( 'activitypub_safe_remote_get_response', $response, $url );
224 122
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 123 if ( $cached ) {
239 124 $cache_duration = $cached;
240 - if ( ! \is_int( $cache_duration ) ) {
125 + if ( ! is_int( $cache_duration ) ) {
241 126 $cache_duration = HOUR_IN_SECONDS;
242 127 }
243 128 \set_transient( $transient_key, $response, $cache_duration );
244 129 }
@@ -253,64 +138,58 @@
253 138 *
254 139 * @return bool True if the URL is a tombstone.
255 140 */
256 141 public static function is_tombstone( $url ) {
257 - \_deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
142 + \do_action( 'activitypub_pre_http_is_tombstone', $url );
258 143
259 - return Tombstone::exists_remote( $url );
144 + $response = \wp_safe_remote_get( $url );
145 + $code = \wp_remote_retrieve_response_code( $response );
146 +
147 + if ( in_array( (int) $code, array( 404, 410 ), true ) ) {
148 + return true;
149 + }
150 +
151 + return false;
260 152 }
261 153
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 154 public static function generate_cache_key( $url ) {
270 155 return 'activitypub_http_' . \md5( $url );
271 156 }
272 157
273 158 /**
274 - * Requests the Data from the Object-URL or Object-Array.
159 + * Requests the Data from the Object-URL or Object-Array
275 160 *
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 161 * @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.
162 + * @param bool $cached If the result should be cached.
287 163 *
288 - * @return array|\WP_Error The Object data as array or WP_Error on failure.
164 + * @return array|WP_Error The Object data as array or WP_Error on failure.
289 165 */
290 166 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;
167 + if ( is_array( $url_or_object ) ) {
168 + if ( array_key_exists( 'id', $url_or_object ) ) {
169 + $url = $url_or_object['id'];
170 + } elseif ( array_key_exists( 'url', $url_or_object ) ) {
171 + $url = $url_or_object['url'];
172 + } else {
173 + return new WP_Error(
174 + 'activitypub_no_valid_actor_identifier',
175 + \__( 'The "actor" identifier is not valid', 'activitypub' ),
176 + array(
177 + 'status' => 404,
178 + 'object' => $url_or_object,
179 + )
180 + );
181 + }
182 + } else {
183 + $url = $url_or_object;
303 184 }
304 185
305 - $url = object_to_uri( $url_or_object );
306 -
307 - if ( Webfinger::is_acct( $url ) ) {
186 + if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
308 187 $url = Webfinger::resolve( $url );
309 188 }
310 189
311 190 if ( ! $url ) {
312 - return new \WP_Error(
191 + return new WP_Error(
313 192 'activitypub_no_valid_actor_identifier',
314 193 \__( 'The "actor" identifier is not valid', 'activitypub' ),
315 194 array(
316 195 'status' => 404,
@@ -318,67 +197,25 @@
318 197 )
319 198 );
320 199 }
321 200
322 - if ( \is_wp_error( $url ) ) {
201 + if ( is_wp_error( $url ) ) {
323 202 return $url;
324 203 }
325 204
326 - $final_url = '';
327 - $object = self::fetch_object( $url, $cached, $final_url );
205 + $transient_key = self::generate_cache_key( $url );
328 206
329 - if ( \is_wp_error( $object ) ) {
330 - return $object;
331 - }
207 + // only check the cache if needed.
208 + if ( $cached ) {
209 + $data = \get_transient( $transient_key );
332 210
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;
211 + if ( $data ) {
212 + return $data;
213 + }
336 214 }
337 215
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 216 if ( ! \wp_http_validate_url( $url ) ) {
380 - return new \WP_Error(
217 + return new WP_Error(
381 218 'activitypub_no_valid_object_url',
382 219 \__( 'The "object" is/has no valid URL', 'activitypub' ),
383 220 array(
384 221 'status' => 400,
@@ -386,23 +223,19 @@
386 223 )
387 224 );
388 225 }
389 226
390 - $response = self::get( $url, array(), $cached );
227 + $response = self::get( $url );
391 228
392 229 if ( \is_wp_error( $response ) ) {
393 230 return $response;
394 231 }
395 232
396 - $effective_url = self::effective_url( $response );
397 - if ( $effective_url ) {
398 - $final_url = $effective_url;
399 - }
233 + $data = \wp_remote_retrieve_body( $response );
234 + $data = \json_decode( $data, true );
400 235
401 - $data = \json_decode( \wp_remote_retrieve_body( $response ), true );
402 -
403 236 if ( ! $data ) {
404 - return new \WP_Error(
237 + return new WP_Error(
405 238 'activitypub_invalid_json',
406 239 \__( 'No valid JSON data', 'activitypub' ),
407 240 array(
408 241 'status' => 400,
@@ -410,39 +243,9 @@
410 243 )
411 244 );
412 245 }
413 246
247 + \set_transient( $transient_key, $data, WEEK_IN_SECONDS );
248 +
414 249 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 250 }
448 251 }