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/functions-request.php +141 -22 8.2.19.2.1 View file →
@@ -49,30 +49,23 @@
49 49 * Filters whether to use Authorized-Fetch.
50 50 *
51 51 * @param boolean $use_authorized_fetch True if Authorized-Fetch is enabled, false otherwise.
52 52 */
53 - return apply_filters( 'activitypub_use_authorized_fetch', $use );
53 + return \apply_filters( 'activitypub_use_authorized_fetch', $use );
54 54 }
55 55
56 56 /**
57 - * Check for Tombstone Objects.
57 + * Check whether the current request should be answered with ActivityPub (JSON).
58 58 *
59 - * @deprecated 7.3.0 Use {@see Tombstone::exists_in_error()}.
60 - * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
59 + * This is the full, plugin-facing check and the one normal plugin code should use. It honors the
60 + * `?activitypub` query var, an Accept header that prefers ActivityPub (via accept_prefers_activitypub()), and the
61 + * `activitypub_is_activitypub_request` filter.
61 62 *
62 - * @param \WP_Error $wp_error A WP_Error-Response of an HTTP-Request.
63 + * It depends on Activitypub\Query, the main `$wp_query`, and the plugin being fully loaded, so it
64 + * must NOT be called from code that runs earlier than that, e.g. a page-cache drop-in deciding a
65 + * cache key on the serve path. Such code has only the Accept header to go on and must call
66 + * accept_prefers_activitypub() directly instead.
63 67 *
64 - * @return boolean True if HTTP-Code is 410 or 404.
65 - */
66 -function is_tombstone( $wp_error ) {
67 - \_deprecated_function( __FUNCTION__, '7.3.0', 'Activitypub\Tombstone::exists_in_error' );
68 -
69 - return Tombstone::exists_in_error( $wp_error );
70 -}
71 -
72 -/**
73 - * Check if a request is for an ActivityPub request.
74 - *
75 68 * @return bool False by default.
76 69 */
77 70 function is_activitypub_request() {
78 71 return Query::get_instance()->is_activitypub_request();
@@ -87,8 +80,108 @@
87 80 return Query::get_instance()->should_negotiate_content();
88 81 }
89 82
90 83 /**
84 + * Whether the client's most-preferred acceptable media type is ActivityPub.
85 + *
86 + * This is only the Accept-header half of content negotiation. Normal plugin code wants
87 + * is_activitypub_request() instead, which also honors the `?activitypub` query var and the
88 + * `activitypub_is_activitypub_request` filter; this function ignores both. Its narrow job is to be
89 + * the single, dependency-free definition of "does this request prefer ActivityPub" that
90 + * is_activitypub_request() and the Surge cache drop-in (integration/surge-cache-config.php) both
91 + * use, so the representation the plugin serves and the one the cache keys on can never disagree. The
92 + * drop-in runs before the plugin loads and cannot call is_activitypub_request(), which is why this
93 + * half lives on its own.
94 + *
95 + * It ranks the listed media types by quality (`;q=`), highest first, breaking ties by the order they
96 + * appear, and reports whether the winner is an ActivityPub type. That is `application/activity+json`,
97 + * or `application/ld+json` carrying the ActivityStreams 2.0 profile
98 + * (`profile="https://www.w3.org/ns/activitystreams"`); plain `application/json` and bare
99 + * `application/ld+json` are not ActivityPub. This respects the client's preference rather than
100 + * demanding an ActivityPub-only header: Mastodon sends
101 + * `application/ld+json;profile="…activitystreams", application/activity+json, text/html;q=0.1`,
102 + * prefers ActivityPub 10:1, and must get it; a browser lists `text/html` at q=1 and gets HTML. A
103 + * media type with no `q` defaults to 1.0; a `q=0` refuses the type and is ignored.
104 + *
105 + * Pass the RAW, unslashed header; both callers must hand it identical bytes but reach that raw form
106 + * differently. The plugin runs after wp_magic_quotes() has addslashed $_SERVER, so it wp_unslash()es
107 + * before calling; the Surge drop-in runs before wp_magic_quotes() and passes its already-raw value as
108 + * is. This function deliberately does NOT unslash or sanitize: stripslashes() here would strip the
109 + * drop-in's genuine backslashes (which the plugin's wp_unslash() preserves), and sanitize_text_field()
110 + * (which the drop-in can't call anyway) would drop bytes such as a `%00`; either would let the two
111 + * paths disagree. Keep it free of side effects and of any PHP 8 polyfill (str_ends_with()), since the
112 + * drop-in runs before the polyfills may be loaded.
113 + *
114 + * @param string $accept The raw (unslashed) Accept header value.
115 + *
116 + * @return bool True when the highest-priority acceptable media type is ActivityPub.
117 + */
118 +function accept_prefers_activitypub( $accept ) {
119 + $winner_quality = 0.0;
120 + $winner_is_ap = false;
121 +
122 + foreach ( \explode( ',', (string) $accept ) as $part ) {
123 + $segments = \explode( ';', $part );
124 + $media_type = \strtolower( \trim( (string) \array_shift( $segments ) ) );
125 +
126 + if ( '' === $media_type ) {
127 + continue;
128 + }
129 +
130 + // Read the quality (default 1.0) and profile parameters, in any order.
131 + $quality = 1.0;
132 + $profile = '';
133 + foreach ( $segments as $param ) {
134 + $param = \trim( $param );
135 + if ( 0 === \stripos( $param, 'q=' ) ) {
136 + // Only a valid number sets the quality; a malformed `q=` keeps the 1.0 default.
137 + $q_value = \trim( \substr( $param, 2 ) );
138 + if ( \is_numeric( $q_value ) ) {
139 + $quality = (float) $q_value;
140 + }
141 + } elseif ( 0 === \stripos( $param, 'profile=' ) ) {
142 + $profile = \strtolower( \trim( \substr( $param, 8 ), '"' ) );
143 + }
144 + }
145 +
146 + // A `q=0` means the client refuses this type; ignore it entirely.
147 + if ( $quality <= 0 ) {
148 + continue;
149 + }
150 +
151 + // Highest quality wins; on a tie the earlier type in the header keeps the lead.
152 + if ( $quality > $winner_quality ) {
153 + $winner_quality = $quality;
154 +
155 + // ActivityPub is `application/activity+json`, or `application/ld+json` with the AS2 profile
156 + // (matched without the scheme so both the http and https profile URIs are accepted).
157 + $winner_is_ap = 'application/activity+json' === $media_type
158 + || ( 'application/ld+json' === $media_type && false !== \strpos( $profile, '://www.w3.org/ns/activitystreams' ) );
159 + }
160 + }
161 +
162 + return $winner_is_ap;
163 +}
164 +
165 +/**
166 + * Mark a REST response non-shareable, on the response object and as a raw HTTP header.
167 + *
168 + * The raw header matters because the REST `_envelope=1` parameter makes WordPress move the response
169 + * headers into the JSON body and serve an outer response without them, so the response-object
170 + * Cache-Control would not reach a page cache or CDN. WordPress core sends its own CORS `Vary: Origin`
171 + * the same raw way for the same reason. The raw header is skipped once the headers are already sent.
172 + *
173 + * @param \WP_REST_Response $response The response to mark.
174 + */
175 +function maybe_set_no_store( $response ) {
176 + $response->header( 'Cache-Control', 'private, no-store, max-age=0' );
177 +
178 + if ( ! \headers_sent() ) {
179 + \header( 'Cache-Control: private, no-store, max-age=0' );
180 + }
181 +}
182 +
183 +/**
91 184 * Requests the Meta-Data from the Actors profile.
92 185 *
93 186 * @param array|string $actor The Actor array or URL.
94 187 * @param bool $cached Optional. Whether the result should be cached. Default true.
@@ -105,9 +198,9 @@
105 198 * @param mixed $pre The value to return instead of the remote metadata.
106 199 * Default false to continue with the remote request.
107 200 * @param string $actor The actor URL.
108 201 */
109 - $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
202 + $pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
110 203 if ( $pre ) {
111 204 return $pre;
112 205 }
113 206
@@ -112,13 +205,13 @@
112 205 }
113 206
114 207 $remote_actor = Remote_Actors::fetch_by_various( $actor );
115 208
116 - if ( is_wp_error( $remote_actor ) ) {
209 + if ( \is_wp_error( $remote_actor ) ) {
117 210 return $remote_actor;
118 211 }
119 212
120 - return json_decode( $remote_actor->post_content, true );
213 + return \json_decode( $remote_actor->post_content, true );
121 214 }
122 215
123 216 /**
124 217 * Resolve a hostname or IP literal to a public IP address.
@@ -143,9 +236,9 @@
143 236 *
144 237 * @return string|false A safe public IP, or false when no safe address is available.
145 238 */
146 239 function resolve_public_host( $host ) {
147 - if ( ! is_string( $host ) || '' === $host ) {
240 + if ( ! \is_string( $host ) || '' === $host ) {
148 241 return false;
149 242 }
150 243
151 244 // Normalise bracketed IPv6 literals (parse_url returns "[::1]").
@@ -150,10 +243,31 @@
150 243
151 244 // Normalise bracketed IPv6 literals (parse_url returns "[::1]").
152 245 $host = \trim( $host, '[]' );
153 246
247 + /**
248 + * Filters whether a non-public host may be used.
249 + *
250 + * Returning true skips this function's private/reserved-range validation and returns the resolved
251 + * address as is, for sites that federate over a private network or intranet. A host that does not
252 + * resolve at all is still rejected.
253 + *
254 + * Note: Callers that fetch through WordPress' safe HTTP APIs (wp_safe_remote_get()/post())
255 + * are still subject to core's own loopback/RFC1918 rejection outside its same-host exception.
256 + * Re-enabling those ranges additionally requires filtering WordPress core (e.g.
257 + * http_request_reject_unsafe_urls).
258 + *
259 + * @param bool $allow Whether to allow the non-public host. Default false.
260 + * @param string $host The host being resolved.
261 + */
262 + $allow_non_public = \apply_filters( 'activitypub_allow_non_public_host', false, $host );
263 +
154 264 // Already an IP literal — validate directly. Accepts IPv4 and IPv6.
155 265 if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
266 + if ( $allow_non_public ) {
267 + return $host;
268 + }
269 +
156 270 if ( is_unsafe_ipv6_literal( $host ) ) {
157 271 return false;
158 272 }
159 273
@@ -197,8 +311,13 @@
197 311 if ( ! $ipv4 && ! $ipv6 ) {
198 312 return false;
199 313 }
200 314
315 + // A host that resolves may be used as is when non-public hosts are explicitly allowed.
316 + if ( $allow_non_public ) {
317 + return $ipv4[0] ?? $ipv6[0];
318 + }
319 +
201 320 foreach ( $ipv4 as $ip ) {
202 321 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
203 322 return false;
204 323 }
@@ -228,9 +347,9 @@
228 347 * @return bool True if the value is an IPv4-mapped IPv6 address.
229 348 */
230 349 function is_ipv4_mapped_ipv6( $ip ) {
231 350 // Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input.
232 - if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
351 + if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
233 352 return false;
234 353 }
235 354
236 355 $packed = \inet_pton( $ip );
@@ -266,9 +385,9 @@
266 385 *
267 386 * @return bool True if the value is an unsafe IPv6 literal.
268 387 */
269 388 function is_unsafe_ipv6_literal( $ip ) {
270 - if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
389 + if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
271 390 return false;
272 391 }
273 392
274 393 $packed = \inet_pton( $ip );