PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.0
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 9.2.0, at includes/functions-request.php

410 lines 14.4 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, a JSON-only Accept header (via is_json_only_accept()), 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 * is_json_only_accept() 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 every media type in an Accept header is a JSON type.
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 a "JSON-only request" that is_activitypub_request() and
90 * the Surge cache drop-in (integration/surge-cache-config.php) both use, so the representation the
91 * plugin serves and the representation the cache keys on can never disagree. The drop-in runs before
92 * the plugin loads and cannot call is_activitypub_request(), which is why this half lives on its own.
93 *
94 * A request counts as JSON only when *every* listed media type is JSON (`application/json`,
95 * `application/ld+json`, `application/activity+json`, or any other `*+json`). A client that also
96 * accepts HTML, such as a browser sending `text/html, application/activity+json`, is not a JSON
97 * request. Keep this free of side effects and WordPress/plugin dependencies so the drop-in can
98 * include this file and call it on its pre-plugin serve path.
99 *
100 * Pass the RAW, unslashed header; both callers must hand it identical bytes but reach that raw form
101 * differently. The plugin runs after wp_magic_quotes() has addslashed $_SERVER, so it wp_unslash()es
102 * before calling; the Surge drop-in runs before wp_magic_quotes() and passes its already-raw value as
103 * is. This function deliberately does NOT unslash or sanitize: stripslashes() here would strip the
104 * drop-in's genuine backslashes (which the plugin's wp_unslash() preserves), and sanitize_text_field()
105 * (which the drop-in can't call anyway) would drop bytes such as a `%00`; either would let the two
106 * paths disagree and cache a JSON response under the html variant. Only `\substr()` (never a PHP 8
107 * polyfill like str_ends_with()) is used for the suffix test, since the drop-in runs before the
108 * polyfills may be loaded.
109 *
110 * @param string $accept The raw (unslashed) Accept header value.
111 *
112 * @return bool True when the header lists at least one media type and all of them are JSON.
113 */
114 function is_json_only_accept( $accept ) {
115 $has_json = false;
116
117 foreach ( \explode( ',', (string) $accept ) as $mime_type ) {
118 // Drop any parameters such as `;q=0.9`.
119 $pos = \strpos( $mime_type, ';' );
120 if ( false !== $pos ) {
121 $mime_type = \substr( $mime_type, 0, $pos );
122 }
123
124 $mime_type = \strtolower( \trim( $mime_type ) );
125
126 if ( '' === $mime_type ) {
127 continue;
128 }
129
130 if ( '/json' !== \substr( $mime_type, -5 ) && '+json' !== \substr( $mime_type, -5 ) ) {
131 return false;
132 }
133
134 $has_json = true;
135 }
136
137 return $has_json;
138 }
139
140 /**
141 * Mark a REST response non-shareable, on the response object and as a raw HTTP header.
142 *
143 * The raw header matters because the REST `_envelope=1` parameter makes WordPress move the response
144 * headers into the JSON body and serve an outer response without them, so the response-object
145 * Cache-Control would not reach a page cache or CDN. WordPress core sends its own CORS `Vary: Origin`
146 * the same raw way for the same reason. The raw header is skipped once the headers are already sent.
147 *
148 * @param \WP_REST_Response $response The response to mark.
149 */
150 function maybe_set_no_store( $response ) {
151 $response->header( 'Cache-Control', 'private, no-store, max-age=0' );
152
153 if ( ! \headers_sent() ) {
154 \header( 'Cache-Control: private, no-store, max-age=0' );
155 }
156 }
157
158 /**
159 * Requests the Meta-Data from the Actors profile.
160 *
161 * @param array|string $actor The Actor array or URL.
162 * @param bool $cached Optional. Whether the result should be cached. Default true.
163 *
164 * @return array|\WP_Error The Actor profile as array or WP_Error on failure.
165 */
166 function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
167 /**
168 * Filters the metadata before it is retrieved from a remote actor.
169 *
170 * Passing a non-false value will effectively short-circuit the remote request,
171 * returning that value instead.
172 *
173 * @param mixed $pre The value to return instead of the remote metadata.
174 * Default false to continue with the remote request.
175 * @param string $actor The actor URL.
176 */
177 $pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
178 if ( $pre ) {
179 return $pre;
180 }
181
182 $remote_actor = Remote_Actors::fetch_by_various( $actor );
183
184 if ( \is_wp_error( $remote_actor ) ) {
185 return $remote_actor;
186 }
187
188 return \json_decode( $remote_actor->post_content, true );
189 }
190
191 /**
192 * Resolve a hostname or IP literal to a public IP address.
193 *
194 * Used as an SSRF guard before opening connections to user-supplied URLs.
195 * `wp_safe_remote_get()` ultimately calls `wp_http_validate_url()`, which has
196 * a same-host carve-out that lets local/private addresses through when the
197 * WordPress site itself is hosted on one. This helper performs an explicit
198 * resolve-and-validate without that carve-out, and returns the resolved IP so
199 * callers can pin the connection to it (defends against DNS rebinding).
200 *
201 * Both IPv4 and IPv6 literals are accepted (bracketed IPv6 like `[::1]` is
202 * normalised first). For hostnames, A records are looked up via
203 * `gethostbynamel()` and AAAA records via `dns_get_record()` when available.
204 * Every returned address is validated against private/reserved ranges; a
205 * single bad address fails the whole resolution, defending against
206 * split-horizon DNS that returns a public answer to one resolver and a
207 * private one to another. IPv4 addresses are preferred over IPv6 when both
208 * exist, mirroring `wp_safe_remote_get()`'s default.
209 *
210 * @param string $host The hostname or IP literal to resolve.
211 *
212 * @return string|false A safe public IP, or false when no safe address is available.
213 */
214 function resolve_public_host( $host ) {
215 if ( ! \is_string( $host ) || '' === $host ) {
216 return false;
217 }
218
219 // Normalise bracketed IPv6 literals (parse_url returns "[::1]").
220 $host = \trim( $host, '[]' );
221
222 /**
223 * Filters whether a non-public host may be used.
224 *
225 * Returning true skips this function's private/reserved-range validation and returns the resolved
226 * address as is, for sites that federate over a private network or intranet. A host that does not
227 * resolve at all is still rejected.
228 *
229 * Note: Callers that fetch through WordPress' safe HTTP APIs (wp_safe_remote_get()/post())
230 * are still subject to core's own loopback/RFC1918 rejection outside its same-host exception.
231 * Re-enabling those ranges additionally requires filtering WordPress core (e.g.
232 * http_request_reject_unsafe_urls).
233 *
234 * @param bool $allow Whether to allow the non-public host. Default false.
235 * @param string $host The host being resolved.
236 */
237 $allow_non_public = \apply_filters( 'activitypub_allow_non_public_host', false, $host );
238
239 // Already an IP literal — validate directly. Accepts IPv4 and IPv6.
240 if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
241 if ( $allow_non_public ) {
242 return $host;
243 }
244
245 if ( is_unsafe_ipv6_literal( $host ) ) {
246 return false;
247 }
248
249 return \filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )
250 ? $host
251 : false;
252 }
253
254 /**
255 * Filters the resolved addresses for a hostname before validation.
256 *
257 * Returning a non-null array of `array{ipv4: string[], ipv6: string[]}` skips
258 * the DNS lookup. Tests use this to exercise the validation/preference logic
259 * without making real DNS queries; production code should leave it null.
260 *
261 * @param array{ipv4: string[], ipv6: string[]}|null $pre Pre-resolved addresses, or null to perform DNS lookup.
262 * @param string $host The hostname being resolved.
263 */
264 $pre = \apply_filters( 'activitypub_pre_resolve_public_host', null, $host );
265
266 if ( \is_array( $pre ) ) {
267 $ipv4 = isset( $pre['ipv4'] ) && \is_array( $pre['ipv4'] ) ? $pre['ipv4'] : array();
268 $ipv6 = isset( $pre['ipv6'] ) && \is_array( $pre['ipv6'] ) ? $pre['ipv6'] : array();
269 } else {
270 $ipv4 = \gethostbynamel( $host ) ?: array();
271 $ipv6 = array();
272
273 if ( \function_exists( 'dns_get_record' ) ) {
274 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on lookup failure; we already handle the empty case.
275 $aaaa = @\dns_get_record( $host, DNS_AAAA );
276 if ( \is_array( $aaaa ) ) {
277 foreach ( $aaaa as $record ) {
278 if ( ! empty( $record['ipv6'] ) ) {
279 $ipv6[] = $record['ipv6'];
280 }
281 }
282 }
283 }
284 }
285
286 if ( ! $ipv4 && ! $ipv6 ) {
287 return false;
288 }
289
290 // A host that resolves may be used as is when non-public hosts are explicitly allowed.
291 if ( $allow_non_public ) {
292 return $ipv4[0] ?? $ipv6[0];
293 }
294
295 foreach ( $ipv4 as $ip ) {
296 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
297 return false;
298 }
299 }
300
301 foreach ( $ipv6 as $ip ) {
302 if ( is_unsafe_ipv6_literal( $ip ) ) {
303 return false;
304 }
305 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
306 return false;
307 }
308 }
309
310 return $ipv4[0] ?? $ipv6[0];
311 }
312
313 /**
314 * Detect IPv4-mapped IPv6 literals (`::ffff:0:0/96`).
315 *
316 * PHP's FILTER_FLAG_NO_RES_RANGE catches this range on some builds but not
317 * others. These forms serve no legitimate purpose for the SSRF-guard callers,
318 * so reject the entire range explicitly via packed-byte comparison.
319 *
320 * @param string $ip An IP literal.
321 *
322 * @return bool True if the value is an IPv4-mapped IPv6 address.
323 */
324 function is_ipv4_mapped_ipv6( $ip ) {
325 // Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input.
326 if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
327 return false;
328 }
329
330 $packed = \inet_pton( $ip );
331
332 return false !== $packed
333 && 16 === \strlen( $packed )
334 && "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 );
335 }
336
337 /**
338 * Detect IPv6 literals in transitional / special-use ranges that PHP's
339 * FILTER_FLAG_NO_RES_RANGE doesn't reliably block.
340 *
341 * Covers, in addition to the IPv4-mapped range handled by
342 * {@see is_ipv4_mapped_ipv6()}:
343 *
344 * - `2002::/16` — 6to4 (RFC 3056). Embeds an IPv4 address in the next 32 bits,
345 * so e.g. `2002:7f00:0001::1` routes back to `127.0.0.1` on a host with 6to4.
346 * - `2001:0000::/32` — Teredo tunneling (RFC 4380). The check matches the
347 * exact 32-bit prefix `2001:0000`, so legitimate `2001::/16` global unicast
348 * allocations (e.g. Google DNS `2001:4860::/32`) are unaffected. The
349 * `2001:db8::/32` documentation range is also blocked, by its own entry
350 * below — they're separate `2001::/16` sub-allocations.
351 * - `2001:db8::/32` — Documentation prefix (RFC 3849); should never be routed.
352 * - `64:ff9b::/96` — NAT64 well-known prefix (RFC 6052).
353 * - `64:ff9b:1::/48` — NAT64 local-use prefix (RFC 8215).
354 * - `100::/64` — Discard prefix (RFC 6666).
355 *
356 * Returns false for IPv4 literals, hostnames, and IPv6 literals outside the
357 * listed ranges.
358 *
359 * @param string $ip An IP literal.
360 *
361 * @return bool True if the value is an unsafe IPv6 literal.
362 */
363 function is_unsafe_ipv6_literal( $ip ) {
364 if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
365 return false;
366 }
367
368 $packed = \inet_pton( $ip );
369 if ( false === $packed || 16 !== \strlen( $packed ) ) {
370 return false;
371 }
372
373 // IPv4-mapped IPv6 prefix.
374 if ( "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 ) ) {
375 return true;
376 }
377
378 // 6to4 prefix.
379 if ( "\x20\x02" === \substr( $packed, 0, 2 ) ) {
380 return true;
381 }
382
383 // Teredo prefix.
384 if ( "\x20\x01\x00\x00" === \substr( $packed, 0, 4 ) ) {
385 return true;
386 }
387
388 // Documentation prefix.
389 if ( "\x20\x01\x0d\xb8" === \substr( $packed, 0, 4 ) ) {
390 return true;
391 }
392
393 // NAT64 well-known prefix.
394 if ( "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 12 ) ) {
395 return true;
396 }
397
398 // NAT64 local-use prefix.
399 if ( "\x00\x64\xff\x9b\x00\x01" === \substr( $packed, 0, 6 ) ) {
400 return true;
401 }
402
403 // Discard prefix.
404 if ( "\x01\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 8 ) ) {
405 return true;
406 }
407
408 return false;
409 }
410