PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.1
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 +162 -30 8.2.19.2.1 View file →
@@ -17,11 +17,11 @@
17 17 class Http {
18 18 /**
19 19 * Send a POST Request with the needed HTTP Headers
20 20 *
21 - * @param string $url The URL endpoint.
22 - * @param string $body The Post Body.
23 - * @param int $user_id The WordPress User-ID.
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 24 *
25 25 * @return array|\WP_Error The POST Response or an WP_Error.
26 26 */
27 27 public static function post( $url, $body, $user_id ) {
@@ -27,11 +27,11 @@
27 27 public static function post( $url, $body, $user_id ) {
28 28 /**
29 29 * Fires before an HTTP POST request is made.
30 30 *
31 - * @param string $url The URL endpoint.
32 - * @param string $body The POST body.
33 - * @param int $user_id The WordPress User ID.
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 34 */
35 35 \do_action( 'activitypub_pre_http_post', $url, $body, $user_id );
36 36
37 37 /**
@@ -48,8 +48,14 @@
48 48 * @param int $timeout The timeout value in seconds. Default 10 seconds.
49 49 */
50 50 $timeout = \apply_filters( 'activitypub_remote_post_timeout', 10 );
51 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 +
52 58 $args = array(
53 59 'timeout' => $timeout,
54 60 'limit_response_size' => 1048576,
55 61 'redirection' => 3,
@@ -60,9 +66,9 @@
60 66 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
61 67 ),
62 68 'body' => $body,
63 69 'key_id' => \json_decode( $body )->actor . '#main-key',
64 - 'private_key' => Actors::get_private_key( $user_id ),
70 + 'private_key' => $private_key,
65 71 'user_id' => $user_id,
66 72 );
67 73
68 74 $response = \wp_safe_remote_post( $url, $args );
@@ -70,9 +76,9 @@
70 76
71 77 if ( $code >= 400 ) {
72 78 $response = new \WP_Error(
73 79 $code,
74 - __( 'Failed HTTP Request', 'activitypub' ),
80 + \__( 'Failed HTTP Request', 'activitypub' ),
75 81 array(
76 82 'status' => $code,
77 83 'response' => $response,
78 84 )
@@ -84,9 +90,9 @@
84 90 *
85 91 * @param array|\WP_Error $response The response of the remote POST request.
86 92 * @param string $url The URL endpoint.
87 93 * @param string $body The Post Body.
88 - * @param int $user_id The WordPress User-ID.
94 + * @param int|null $user_id The WordPress User-ID, or null when signing with the Application key.
89 95 */
90 96 \do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
91 97
92 98 return $response;
@@ -97,9 +103,10 @@
97 103 *
98 104 * @param string $url The URL endpoint.
99 105 * @param array $args Optional. Additional arguments to customize the request.
100 106 * - 'headers': Array of headers to override defaults.
101 - * @param bool|int $cached Optional. Whether to return cached results, or cache duration. Default false.
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.
102 109 *
103 110 * @return array|\WP_Error The GET Response or a WP_Error.
104 111 */
105 112 public static function get( $url, $args = array(), $cached = false ) {
@@ -164,10 +171,10 @@
164 171 'Accept' => 'application/activity+json',
165 172 'Content-Type' => 'application/activity+json',
166 173 'Date' => \gmdate( 'D, d M Y H:i:s T' ),
167 174 ),
168 - 'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key',
169 - 'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ),
175 + 'key_id' => Application::get_key_id(),
176 + 'private_key' => Application::get_private_key(),
170 177 );
171 178
172 179 $args = \wp_parse_args( $args, $defaults );
173 180 $args['headers'] = \wp_parse_args( $args['headers'], $defaults['headers'] );
@@ -175,26 +182,36 @@
175 182 $response = \wp_safe_remote_get( $url, $args );
176 183 $code = \wp_remote_retrieve_response_code( $response );
177 184
178 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 +
179 189 if ( ! $code ) {
180 190 $code = 0;
181 191 }
182 - $response = new \WP_Error( $code, __( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
192 + $response = new \WP_Error( $code, \__( 'Failed HTTP Request', 'activitypub' ), array( 'status' => $code ) );
183 193
184 194 /*
185 - * Always cache errors to prevent repeated timeout waits.
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 + *
186 201 * - Retriable errors (timeouts, 5xx): 1 minute (server may recover quickly).
187 202 * - Other errors (4xx): 15 minutes (client errors are more permanent).
188 203 */
189 - if ( \in_array( $code, ACTIVITYPUB_RETRY_ERROR_CODES, true ) || 0 === $code ) {
190 - $cache_duration = MINUTE_IN_SECONDS;
191 - } else {
192 - $cache_duration = 15 * MINUTE_IN_SECONDS;
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 );
193 212 }
194 213
195 - \set_transient( $transient_key, $response, $cache_duration );
196 -
197 214 return $response;
198 215 }
199 216
200 217 /**
@@ -204,15 +221,29 @@
204 221 * @param string $url The URL endpoint.
205 222 */
206 223 \do_action( 'activitypub_safe_remote_get_response', $response, $url );
207 224
208 - // Always cache successful responses.
209 - $cache_duration = $cached;
210 - if ( ! is_int( $cache_duration ) ) {
211 - $cache_duration = HOUR_IN_SECONDS;
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;
212 235 }
213 - \set_transient( $transient_key, $response, $cache_duration );
214 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 +
215 246 return $response;
216 247 }
217 248
218 249 /**
@@ -222,9 +253,9 @@
222 253 *
223 254 * @return bool True if the URL is a tombstone.
224 255 */
225 256 public static function is_tombstone( $url ) {
226 - _deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
257 + \_deprecated_function( __METHOD__, '7.3.0', 'Activitypub\Tombstone::exists_remote' );
227 258
228 259 return Tombstone::exists_remote( $url );
229 260 }
230 261
@@ -241,8 +272,17 @@
241 272
242 273 /**
243 274 * Requests the Data from the Object-URL or Object-Array.
244 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 + *
245 285 * @param array|string $url_or_object The Object or the Object URL.
246 286 * @param bool $cached Optional. Whether the result should be cached. Default true.
247 287 *
248 288 * @return array|\WP_Error The Object data as array or WP_Error on failure.
@@ -250,12 +290,15 @@
250 290 public static function get_remote_object( $url_or_object, $cached = true ) {
251 291 /**
252 292 * Filters the preemptive return value of a remote object request.
253 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 + *
254 297 * @param array|string|null $response The response.
255 298 * @param array|string|null $url_or_object The Object or the Object URL.
256 299 */
257 - $response = apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
300 + $response = \apply_filters( 'activitypub_pre_http_get_remote_object', null, $url_or_object );
258 301 if ( null !== $response ) {
259 302 return $response;
260 303 }
261 304
@@ -260,9 +303,9 @@
260 303 }
261 304
262 305 $url = object_to_uri( $url_or_object );
263 306
264 - if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $url ) ) {
307 + if ( Webfinger::is_acct( $url ) ) {
265 308 $url = Webfinger::resolve( $url );
266 309 }
267 310
268 311 if ( ! $url ) {
@@ -279,8 +322,61 @@
279 322 if ( \is_wp_error( $url ) ) {
280 323 return $url;
281 324 }
282 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 +
283 379 if ( ! \wp_http_validate_url( $url ) ) {
284 380 return new \WP_Error(
285 381 'activitypub_no_valid_object_url',
286 382 \__( 'The "object" is/has no valid URL', 'activitypub' ),
@@ -296,11 +392,15 @@
296 392 if ( \is_wp_error( $response ) ) {
297 393 return $response;
298 394 }
299 395
300 - $data = \wp_remote_retrieve_body( $response );
301 - $data = \json_decode( $data, true );
396 + $effective_url = self::effective_url( $response );
397 + if ( $effective_url ) {
398 + $final_url = $effective_url;
399 + }
302 400
401 + $data = \json_decode( \wp_remote_retrieve_body( $response ), true );
402 +
303 403 if ( ! $data ) {
304 404 return new \WP_Error(
305 405 'activitypub_invalid_json',
306 406 \__( 'No valid JSON data', 'activitypub' ),
@@ -311,6 +411,38 @@
311 411 );
312 412 }
313 413
314 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;
315 447 }
316 448 }