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-query.php +101 -17 8.2.19.2.1 View file →
@@ -6,11 +6,13 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 +use Activitypub\Activity\Extended_Object\Feature_Authorization;
10 11 use Activitypub\Activity\Extended_Object\Quote_Authorization;
11 12 use Activitypub\Collection\Actors;
12 13 use Activitypub\Collection\Outbox;
14 +use Activitypub\Handler\Feature_Request;
13 15 use Activitypub\Transformer\Factory;
14 16
15 17 /**
16 18 * Singleton class to handle and store the ActivityPub query.
@@ -138,10 +140,17 @@
138 140 */
139 141 private function prepare_activitypub_data() {
140 142 $queried_object = $this->get_queried_object();
141 143
142 - if ( $queried_object instanceof \WP_Post && \get_query_var( 'stamp' ) ) {
143 - return $this->maybe_get_stamp();
144 + if ( \get_query_var( 'stamp' ) ) {
145 + if ( $queried_object instanceof \WP_Post ) {
146 + return $this->maybe_get_stamp();
147 + }
148 +
149 + // Note: the blog actor's `actor` query var is '0', which is falsy but valid.
150 + if ( $queried_object instanceof \WP_User || '' !== \get_query_var( 'actor' ) ) {
151 + return $this->maybe_get_actor_stamp();
152 + }
144 153 }
145 154
146 155 // Check for Outbox Activity.
147 156 if (
@@ -219,9 +228,9 @@
219 228 * Filters the queried object.
220 229 *
221 230 * @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
222 231 */
223 - return apply_filters( 'activitypub_queried_object', $queried_object );
232 + return \apply_filters( 'activitypub_queried_object', $queried_object );
224 233 }
225 234
226 235 /**
227 236 * Get the virtual object.
@@ -226,12 +235,11 @@
226 235 /**
227 236 * Get the virtual object.
228 237 *
229 238 * Virtual objects are objects that are not stored in the database, but are created on the fly.
230 - * The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor.
239 + * The plugin currently supports one virtual object: The Blog-Actor.
231 240 *
232 241 * @see \Activitypub\Model\Blog
233 - * @see \Activitypub\Model\Application
234 242 *
235 243 * @return object|null The virtual object.
236 244 */
237 245 protected function maybe_get_virtual_object() {
@@ -242,9 +250,9 @@
242 250 }
243 251
244 252 $author_id = url_to_authorid( $url );
245 253
246 - if ( ! is_numeric( $author_id ) ) {
254 + if ( ! \is_numeric( $author_id ) ) {
247 255 $author_id = $url;
248 256 }
249 257
250 258 $user = Actors::get_by_various( $author_id );
@@ -291,19 +299,25 @@
291 299 $this->is_activitypub_request = true;
292 300
293 301 // The other (more common) option to make an ActivityPub request is to send an Accept header.
294 302 } elseif ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
295 - $accept = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
296 -
297 303 /*
298 - * $accept can be a single value, or a comma separated list of values.
299 - * We want to support both scenarios,
300 - * and return true when the header includes at least one of the following:
301 - * - application/activity+json
302 - * - application/ld+json
303 - * - application/json
304 + * The Accept-header decision is delegated to accept_prefers_activitypub() so the plugin and the
305 + * Surge cache drop-in classify byte-for-byte identically. Both must hand it the same raw
306 + * header, and they reach that raw form differently on purpose: this runs after
307 + * wp_magic_quotes() has addslashed $_SERVER, so it wp_unslash()es to recover the original
308 + * bytes; the drop-in runs before wp_magic_quotes() and passes its already-raw value
309 + * untouched. Do NOT sanitize it (the drop-in can't, its sanitizers aren't loaded yet) and
310 + * the helper must not stripslashes() either (that would corrupt the drop-in's genuine
311 + * bytes). It is only used to pick a content type, never stored or echoed.
312 + *
313 + * The request is ActivityPub when the highest-priority (by `q`, then order) media type is
314 + * an ActivityPub type (`application/activity+json`, or `application/ld+json` with the AS2
315 + * profile). A browser (`text/html` at q=1) gets the normal page; a client that prefers
316 + * ActivityPub but also accepts HTML as a low-`q` fallback (Mastodon) gets ActivityPub.
304 317 */
305 - if ( \preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
318 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Classified only; wp_unslash() recovers the raw bytes the pre-plugin cache path sees, and it must not be sanitized.
319 + if ( accept_prefers_activitypub( \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ) {
306 320 \defined( 'ACTIVITYPUB_REQUEST' ) || \define( 'ACTIVITYPUB_REQUEST', true );
307 321 $this->is_activitypub_request = true;
308 322 }
309 323 }
@@ -402,10 +416,15 @@
402 416 }
403 417
404 418 $post = $this->get_queried_object();
405 419
406 - // Ensure the meta belongs to the queried post to prevent arbitrary meta disclosure.
407 - if ( (int) $meta->post_id !== $post->ID ) {
420 + /*
421 + * Only quote-authorization meta may be reflected as a stamp, and only for the queried
422 + * post. Checking the post id alone would still let an unauthenticated request read any
423 + * of that post's meta rows (e.g. _edit_lock or private custom fields) by guessing a
424 + * meta_id, so the meta key is verified too.
425 + */
426 + if ( '_activitypub_quoted_by' !== $meta->meta_key || (int) $meta->post_id !== $post->ID ) {
408 427 return false;
409 428 }
410 429
411 430 $user_uri = get_user_id( $post->post_author );
@@ -429,8 +448,73 @@
429 448 $activitypub_object->set_interaction_target( get_post_id( $post->ID ) );
430 449
431 450 $this->activitypub_object = $activitypub_object;
432 451 $this->activitypub_object_id = $activitypub_object->get_id();
452 +
453 + return true;
454 + }
455 +
456 + /**
457 + * Maybe get a FeatureAuthorization object from an actor-scoped stamp.
458 + *
459 + * Resolves URLs of the form `?actor=USER_ID&stamp=STAMP_ID` against the
460 + * actor's stamp store, see {@see Feature_Request::get_stamp()}. Ownership
461 + * is enforced by resolving the stamp scoped to the queried actor, which
462 + * includes the blog actor (`actor=0`).
463 + *
464 + * @return bool True if a FeatureAuthorization was prepared, false otherwise.
465 + */
466 + private function maybe_get_actor_stamp() {
467 + $stamp_id = (int) \get_query_var( 'stamp' );
468 + $actor_var = \get_query_var( 'actor' );
469 +
470 + if ( ! $stamp_id ) {
471 + return false;
472 + }
473 +
474 + if ( '' === $actor_var ) {
475 + $queried = $this->get_queried_object();
476 + if ( ! $queried instanceof \WP_User ) {
477 + return false;
478 + }
479 +
480 + $actor_id = (int) $queried->ID;
481 + } else {
482 + // Values like '0e1' or '1.5' pass is_numeric() but cast to 0/1 and alias
483 + // an actor, so require a plain decimal integer before casting.
484 + if ( ! \ctype_digit( (string) $actor_var ) ) {
485 + return false;
486 + }
487 +
488 + $actor_id = (int) $actor_var;
489 + }
490 +
491 + $instrument = Feature_Request::get_stamp( $actor_id, $stamp_id );
492 + if ( null === $instrument ) {
493 + return false;
494 + }
495 +
496 + $actor = Actors::get_by_id( $actor_id );
497 + if ( \is_wp_error( $actor ) ) {
498 + return false;
499 + }
500 +
501 + $stamp_url = \add_query_arg(
502 + array(
503 + 'actor' => $actor_id,
504 + 'stamp' => $stamp_id,
505 + ),
506 + \home_url( '/' )
507 + );
508 +
509 + $authorization = new Feature_Authorization();
510 + $authorization->set_id( $stamp_url );
511 + $authorization->set_attributed_to( $actor->get_id() );
512 + $authorization->set_interacting_object( $instrument );
513 + $authorization->set_interaction_target( $actor->get_id() );
514 +
515 + $this->activitypub_object = $authorization;
516 + $this->activitypub_object_id = $authorization->get_id();
433 517
434 518 return true;
435 519 }
436 520 }