| @@ -49,23 +49,30 @@ | ||
| 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 whether the current request should be answered with ActivityPub (JSON). | |
| 57 | + * Check for Tombstone Objects. | |
| 58 | 58 | * |
| 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. | |
| 59 | + * @deprecated 7.3.0 Use {@see Tombstone::exists_in_error()}. | |
| 60 | + * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox | |
| 62 | 61 | * |
| 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. | |
| 62 | + * @param \WP_Error $wp_error A WP_Error-Response of an HTTP-Request. | |
| 67 | 63 | * |
| 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 | + * | |
| 68 | 75 | * @return bool False by default. |
| 69 | 76 | */ |
| 70 | 77 | function is_activitypub_request() { |
| 71 | 78 | return Query::get_instance()->is_activitypub_request(); |
| @@ -80,108 +87,8 @@ | ||
| 80 | 87 | return Query::get_instance()->should_negotiate_content(); |
| 81 | 88 | } |
| 82 | 89 | |
| 83 | 90 | /** |
| 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 | -/** | |
| 184 | 91 | * Requests the Meta-Data from the Actors profile. |
| 185 | 92 | * |
| 186 | 93 | * @param array|string $actor The Actor array or URL. |
| 187 | 94 | * @param bool $cached Optional. Whether the result should be cached. Default true. |
| @@ -198,9 +105,9 @@ | ||
| 198 | 105 | * @param mixed $pre The value to return instead of the remote metadata. |
| 199 | 106 | * Default false to continue with the remote request. |
| 200 | 107 | * @param string $actor The actor URL. |
| 201 | 108 | */ |
| 202 | - $pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor ); | |
| 109 | + $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor ); | |
| 203 | 110 | if ( $pre ) { |
| 204 | 111 | return $pre; |
| 205 | 112 | } |
| 206 | 113 | |
| @@ -205,230 +112,10 @@ | ||
| 205 | 112 | } |
| 206 | 113 | |
| 207 | 114 | $remote_actor = Remote_Actors::fetch_by_various( $actor ); |
| 208 | 115 | |
| 209 | - if ( \is_wp_error( $remote_actor ) ) { | |
| 116 | + if ( is_wp_error( $remote_actor ) ) { | |
| 210 | 117 | return $remote_actor; |
| 211 | 118 | } |
| 212 | 119 | |
| 213 | - return \json_decode( $remote_actor->post_content, true ); | |
| 214 | -} | |
| 215 | - | |
| 216 | -/** | |
| 217 | - * Resolve a hostname or IP literal to a public IP address. | |
| 218 | - * | |
| 219 | - * Used as an SSRF guard before opening connections to user-supplied URLs. | |
| 220 | - * `wp_safe_remote_get()` ultimately calls `wp_http_validate_url()`, which has | |
| 221 | - * a same-host carve-out that lets local/private addresses through when the | |
| 222 | - * WordPress site itself is hosted on one. This helper performs an explicit | |
| 223 | - * resolve-and-validate without that carve-out, and returns the resolved IP so | |
| 224 | - * callers can pin the connection to it (defends against DNS rebinding). | |
| 225 | - * | |
| 226 | - * Both IPv4 and IPv6 literals are accepted (bracketed IPv6 like `[::1]` is | |
| 227 | - * normalised first). For hostnames, A records are looked up via | |
| 228 | - * `gethostbynamel()` and AAAA records via `dns_get_record()` when available. | |
| 229 | - * Every returned address is validated against private/reserved ranges; a | |
| 230 | - * single bad address fails the whole resolution, defending against | |
| 231 | - * split-horizon DNS that returns a public answer to one resolver and a | |
| 232 | - * private one to another. IPv4 addresses are preferred over IPv6 when both | |
| 233 | - * exist, mirroring `wp_safe_remote_get()`'s default. | |
| 234 | - * | |
| 235 | - * @param string $host The hostname or IP literal to resolve. | |
| 236 | - * | |
| 237 | - * @return string|false A safe public IP, or false when no safe address is available. | |
| 238 | - */ | |
| 239 | -function resolve_public_host( $host ) { | |
| 240 | - if ( ! \is_string( $host ) || '' === $host ) { | |
| 241 | - return false; | |
| 242 | - } | |
| 243 | - | |
| 244 | - // Normalise bracketed IPv6 literals (parse_url returns "[::1]"). | |
| 245 | - $host = \trim( $host, '[]' ); | |
| 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 | - | |
| 264 | - // Already an IP literal — validate directly. Accepts IPv4 and IPv6. | |
| 265 | - if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) { | |
| 266 | - if ( $allow_non_public ) { | |
| 267 | - return $host; | |
| 268 | - } | |
| 269 | - | |
| 270 | - if ( is_unsafe_ipv6_literal( $host ) ) { | |
| 271 | - return false; | |
| 272 | - } | |
| 273 | - | |
| 274 | - return \filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) | |
| 275 | - ? $host | |
| 276 | - : false; | |
| 277 | - } | |
| 278 | - | |
| 279 | - /** | |
| 280 | - * Filters the resolved addresses for a hostname before validation. | |
| 281 | - * | |
| 282 | - * Returning a non-null array of `array{ipv4: string[], ipv6: string[]}` skips | |
| 283 | - * the DNS lookup. Tests use this to exercise the validation/preference logic | |
| 284 | - * without making real DNS queries; production code should leave it null. | |
| 285 | - * | |
| 286 | - * @param array{ipv4: string[], ipv6: string[]}|null $pre Pre-resolved addresses, or null to perform DNS lookup. | |
| 287 | - * @param string $host The hostname being resolved. | |
| 288 | - */ | |
| 289 | - $pre = \apply_filters( 'activitypub_pre_resolve_public_host', null, $host ); | |
| 290 | - | |
| 291 | - if ( \is_array( $pre ) ) { | |
| 292 | - $ipv4 = isset( $pre['ipv4'] ) && \is_array( $pre['ipv4'] ) ? $pre['ipv4'] : array(); | |
| 293 | - $ipv6 = isset( $pre['ipv6'] ) && \is_array( $pre['ipv6'] ) ? $pre['ipv6'] : array(); | |
| 294 | - } else { | |
| 295 | - $ipv4 = \gethostbynamel( $host ) ?: array(); | |
| 296 | - $ipv6 = array(); | |
| 297 | - | |
| 298 | - if ( \function_exists( 'dns_get_record' ) ) { | |
| 299 | - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on lookup failure; we already handle the empty case. | |
| 300 | - $aaaa = @\dns_get_record( $host, DNS_AAAA ); | |
| 301 | - if ( \is_array( $aaaa ) ) { | |
| 302 | - foreach ( $aaaa as $record ) { | |
| 303 | - if ( ! empty( $record['ipv6'] ) ) { | |
| 304 | - $ipv6[] = $record['ipv6']; | |
| 305 | - } | |
| 306 | - } | |
| 307 | - } | |
| 308 | - } | |
| 309 | - } | |
| 310 | - | |
| 311 | - if ( ! $ipv4 && ! $ipv6 ) { | |
| 312 | - return false; | |
| 313 | - } | |
| 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 | - | |
| 320 | - foreach ( $ipv4 as $ip ) { | |
| 321 | - if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { | |
| 322 | - return false; | |
| 323 | - } | |
| 324 | - } | |
| 325 | - | |
| 326 | - foreach ( $ipv6 as $ip ) { | |
| 327 | - if ( is_unsafe_ipv6_literal( $ip ) ) { | |
| 328 | - return false; | |
| 329 | - } | |
| 330 | - if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { | |
| 331 | - return false; | |
| 332 | - } | |
| 333 | - } | |
| 334 | - | |
| 335 | - return $ipv4[0] ?? $ipv6[0]; | |
| 336 | -} | |
| 337 | - | |
| 338 | -/** | |
| 339 | - * Detect IPv4-mapped IPv6 literals (`::ffff:0:0/96`). | |
| 340 | - * | |
| 341 | - * PHP's FILTER_FLAG_NO_RES_RANGE catches this range on some builds but not | |
| 342 | - * others. These forms serve no legitimate purpose for the SSRF-guard callers, | |
| 343 | - * so reject the entire range explicitly via packed-byte comparison. | |
| 344 | - * | |
| 345 | - * @param string $ip An IP literal. | |
| 346 | - * | |
| 347 | - * @return bool True if the value is an IPv4-mapped IPv6 address. | |
| 348 | - */ | |
| 349 | -function is_ipv4_mapped_ipv6( $ip ) { | |
| 350 | - // Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input. | |
| 351 | - if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { | |
| 352 | - return false; | |
| 353 | - } | |
| 354 | - | |
| 355 | - $packed = \inet_pton( $ip ); | |
| 356 | - | |
| 357 | - return false !== $packed | |
| 358 | - && 16 === \strlen( $packed ) | |
| 359 | - && "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 ); | |
| 360 | -} | |
| 361 | - | |
| 362 | -/** | |
| 363 | - * Detect IPv6 literals in transitional / special-use ranges that PHP's | |
| 364 | - * FILTER_FLAG_NO_RES_RANGE doesn't reliably block. | |
| 365 | - * | |
| 366 | - * Covers, in addition to the IPv4-mapped range handled by | |
| 367 | - * {@see is_ipv4_mapped_ipv6()}: | |
| 368 | - * | |
| 369 | - * - `2002::/16` — 6to4 (RFC 3056). Embeds an IPv4 address in the next 32 bits, | |
| 370 | - * so e.g. `2002:7f00:0001::1` routes back to `127.0.0.1` on a host with 6to4. | |
| 371 | - * - `2001:0000::/32` — Teredo tunneling (RFC 4380). The check matches the | |
| 372 | - * exact 32-bit prefix `2001:0000`, so legitimate `2001::/16` global unicast | |
| 373 | - * allocations (e.g. Google DNS `2001:4860::/32`) are unaffected. The | |
| 374 | - * `2001:db8::/32` documentation range is also blocked, by its own entry | |
| 375 | - * below — they're separate `2001::/16` sub-allocations. | |
| 376 | - * - `2001:db8::/32` — Documentation prefix (RFC 3849); should never be routed. | |
| 377 | - * - `64:ff9b::/96` — NAT64 well-known prefix (RFC 6052). | |
| 378 | - * - `64:ff9b:1::/48` — NAT64 local-use prefix (RFC 8215). | |
| 379 | - * - `100::/64` — Discard prefix (RFC 6666). | |
| 380 | - * | |
| 381 | - * Returns false for IPv4 literals, hostnames, and IPv6 literals outside the | |
| 382 | - * listed ranges. | |
| 383 | - * | |
| 384 | - * @param string $ip An IP literal. | |
| 385 | - * | |
| 386 | - * @return bool True if the value is an unsafe IPv6 literal. | |
| 387 | - */ | |
| 388 | -function is_unsafe_ipv6_literal( $ip ) { | |
| 389 | - if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { | |
| 390 | - return false; | |
| 391 | - } | |
| 392 | - | |
| 393 | - $packed = \inet_pton( $ip ); | |
| 394 | - if ( false === $packed || 16 !== \strlen( $packed ) ) { | |
| 395 | - return false; | |
| 396 | - } | |
| 397 | - | |
| 398 | - // IPv4-mapped IPv6 prefix. | |
| 399 | - if ( "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 ) ) { | |
| 400 | - return true; | |
| 401 | - } | |
| 402 | - | |
| 403 | - // 6to4 prefix. | |
| 404 | - if ( "\x20\x02" === \substr( $packed, 0, 2 ) ) { | |
| 405 | - return true; | |
| 406 | - } | |
| 407 | - | |
| 408 | - // Teredo prefix. | |
| 409 | - if ( "\x20\x01\x00\x00" === \substr( $packed, 0, 4 ) ) { | |
| 410 | - return true; | |
| 411 | - } | |
| 412 | - | |
| 413 | - // Documentation prefix. | |
| 414 | - if ( "\x20\x01\x0d\xb8" === \substr( $packed, 0, 4 ) ) { | |
| 415 | - return true; | |
| 416 | - } | |
| 417 | - | |
| 418 | - // NAT64 well-known prefix. | |
| 419 | - if ( "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 12 ) ) { | |
| 420 | - return true; | |
| 421 | - } | |
| 422 | - | |
| 423 | - // NAT64 local-use prefix. | |
| 424 | - if ( "\x00\x64\xff\x9b\x00\x01" === \substr( $packed, 0, 6 ) ) { | |
| 425 | - return true; | |
| 426 | - } | |
| 427 | - | |
| 428 | - // Discard prefix. | |
| 429 | - if ( "\x01\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 8 ) ) { | |
| 430 | - return true; | |
| 431 | - } | |
| 432 | - | |
| 433 | - return false; | |
| 120 | + return json_decode( $remote_actor->post_content, true ); | |
| 434 | 121 | } |