PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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
activitypub / includes / functions-request.php

functions-request.php in ActivityPub trunk, at includes/functions-request.php

435 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Request functions.
4 *
5 * Functions for HTTP requests and remote communication.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub;
11
12 use Activitypub\Collection\Remote_Actors;
13
14 /**
15 * Send a POST request to a remote server.
16 *
17 * @param string $url The URL endpoint.
18 * @param string $body The Post Body.
19 * @param int $user_id The WordPress user ID.
20 *
21 * @return array|\WP_Error The POST Response or an WP_Error.
22 */
23 function safe_remote_post( $url, $body, $user_id ) {
24 return Http::post( $url, $body, $user_id );
25 }
26
27 /**
28 * Send a GET request to a remote server.
29 *
30 * @param string $url The URL endpoint.
31 *
32 * @return array|\WP_Error The GET Response or an WP_Error.
33 */
34 function safe_remote_get( $url ) {
35 return Http::get( $url );
36 }
37
38 /**
39 * Check if Authorized-Fetch is enabled.
40 *
41 * @see https://docs.joinmastodon.org/admin/config/#authorized_fetch
42 *
43 * @return boolean True if Authorized-Fetch is enabled, false otherwise.
44 */
45 function use_authorized_fetch() {
46 $use = (bool) \get_option( 'activitypub_authorized_fetch' );
47
48 /**
49 * Filters whether to use Authorized-Fetch.
50 *
51 * @param boolean $use_authorized_fetch True if Authorized-Fetch is enabled, false otherwise.
52 */
53 return \apply_filters( 'activitypub_use_authorized_fetch', $use );
54 }
55
56 /**
57 * Check whether the current request should be answered with ActivityPub (JSON).
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.
62 *
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.
67 *
68 * @return bool False by default.
69 */
70 function is_activitypub_request() {
71 return Query::get_instance()->is_activitypub_request();
72 }
73
74 /**
75 * Check if content negotiation is allowed for a request.
76 *
77 * @return bool True if content negotiation is allowed, false otherwise.
78 */
79 function should_negotiate_content() {
80 return Query::get_instance()->should_negotiate_content();
81 }
82
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 /**
184 * Requests the Meta-Data from the Actors profile.
185 *
186 * @param array|string $actor The Actor array or URL.
187 * @param bool $cached Optional. Whether the result should be cached. Default true.
188 *
189 * @return array|\WP_Error The Actor profile as array or WP_Error on failure.
190 */
191 function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
192 /**
193 * Filters the metadata before it is retrieved from a remote actor.
194 *
195 * Passing a non-false value will effectively short-circuit the remote request,
196 * returning that value instead.
197 *
198 * @param mixed $pre The value to return instead of the remote metadata.
199 * Default false to continue with the remote request.
200 * @param string $actor The actor URL.
201 */
202 $pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
203 if ( $pre ) {
204 return $pre;
205 }
206
207 $remote_actor = Remote_Actors::fetch_by_various( $actor );
208
209 if ( \is_wp_error( $remote_actor ) ) {
210 return $remote_actor;
211 }
212
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;
434 }
435