| @@ -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 ); |