PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.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.1.0, at includes/functions-request.php

326 lines 10.3 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 if a request is for an ActivityPub request.
58 *
59 * @return bool False by default.
60 */
61 function is_activitypub_request() {
62 return Query::get_instance()->is_activitypub_request();
63 }
64
65 /**
66 * Check if content negotiation is allowed for a request.
67 *
68 * @return bool True if content negotiation is allowed, false otherwise.
69 */
70 function should_negotiate_content() {
71 return Query::get_instance()->should_negotiate_content();
72 }
73
74 /**
75 * Requests the Meta-Data from the Actors profile.
76 *
77 * @param array|string $actor The Actor array or URL.
78 * @param bool $cached Optional. Whether the result should be cached. Default true.
79 *
80 * @return array|\WP_Error The Actor profile as array or WP_Error on failure.
81 */
82 function get_remote_metadata_by_actor( $actor, $cached = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
83 /**
84 * Filters the metadata before it is retrieved from a remote actor.
85 *
86 * Passing a non-false value will effectively short-circuit the remote request,
87 * returning that value instead.
88 *
89 * @param mixed $pre The value to return instead of the remote metadata.
90 * Default false to continue with the remote request.
91 * @param string $actor The actor URL.
92 */
93 $pre = \apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
94 if ( $pre ) {
95 return $pre;
96 }
97
98 $remote_actor = Remote_Actors::fetch_by_various( $actor );
99
100 if ( \is_wp_error( $remote_actor ) ) {
101 return $remote_actor;
102 }
103
104 return \json_decode( $remote_actor->post_content, true );
105 }
106
107 /**
108 * Resolve a hostname or IP literal to a public IP address.
109 *
110 * Used as an SSRF guard before opening connections to user-supplied URLs.
111 * `wp_safe_remote_get()` ultimately calls `wp_http_validate_url()`, which has
112 * a same-host carve-out that lets local/private addresses through when the
113 * WordPress site itself is hosted on one. This helper performs an explicit
114 * resolve-and-validate without that carve-out, and returns the resolved IP so
115 * callers can pin the connection to it (defends against DNS rebinding).
116 *
117 * Both IPv4 and IPv6 literals are accepted (bracketed IPv6 like `[::1]` is
118 * normalised first). For hostnames, A records are looked up via
119 * `gethostbynamel()` and AAAA records via `dns_get_record()` when available.
120 * Every returned address is validated against private/reserved ranges; a
121 * single bad address fails the whole resolution, defending against
122 * split-horizon DNS that returns a public answer to one resolver and a
123 * private one to another. IPv4 addresses are preferred over IPv6 when both
124 * exist, mirroring `wp_safe_remote_get()`'s default.
125 *
126 * @param string $host The hostname or IP literal to resolve.
127 *
128 * @return string|false A safe public IP, or false when no safe address is available.
129 */
130 function resolve_public_host( $host ) {
131 if ( ! \is_string( $host ) || '' === $host ) {
132 return false;
133 }
134
135 // Normalise bracketed IPv6 literals (parse_url returns "[::1]").
136 $host = \trim( $host, '[]' );
137
138 /**
139 * Filters whether a non-public host may be used.
140 *
141 * Returning true skips this function's private/reserved-range validation and returns the resolved
142 * address as is, for sites that federate over a private network or intranet. A host that does not
143 * resolve at all is still rejected.
144 *
145 * Note: Callers that fetch through WordPress' safe HTTP APIs (wp_safe_remote_get()/post())
146 * are still subject to core's own loopback/RFC1918 rejection outside its same-host exception.
147 * Re-enabling those ranges additionally requires filtering WordPress core (e.g.
148 * http_request_reject_unsafe_urls).
149 *
150 * @param bool $allow Whether to allow the non-public host. Default false.
151 * @param string $host The host being resolved.
152 */
153 $allow_non_public = \apply_filters( 'activitypub_allow_non_public_host', false, $host );
154
155 // Already an IP literal — validate directly. Accepts IPv4 and IPv6.
156 if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
157 if ( $allow_non_public ) {
158 return $host;
159 }
160
161 if ( is_unsafe_ipv6_literal( $host ) ) {
162 return false;
163 }
164
165 return \filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )
166 ? $host
167 : false;
168 }
169
170 /**
171 * Filters the resolved addresses for a hostname before validation.
172 *
173 * Returning a non-null array of `array{ipv4: string[], ipv6: string[]}` skips
174 * the DNS lookup. Tests use this to exercise the validation/preference logic
175 * without making real DNS queries; production code should leave it null.
176 *
177 * @param array{ipv4: string[], ipv6: string[]}|null $pre Pre-resolved addresses, or null to perform DNS lookup.
178 * @param string $host The hostname being resolved.
179 */
180 $pre = \apply_filters( 'activitypub_pre_resolve_public_host', null, $host );
181
182 if ( \is_array( $pre ) ) {
183 $ipv4 = isset( $pre['ipv4'] ) && \is_array( $pre['ipv4'] ) ? $pre['ipv4'] : array();
184 $ipv6 = isset( $pre['ipv6'] ) && \is_array( $pre['ipv6'] ) ? $pre['ipv6'] : array();
185 } else {
186 $ipv4 = \gethostbynamel( $host ) ?: array();
187 $ipv6 = array();
188
189 if ( \function_exists( 'dns_get_record' ) ) {
190 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on lookup failure; we already handle the empty case.
191 $aaaa = @\dns_get_record( $host, DNS_AAAA );
192 if ( \is_array( $aaaa ) ) {
193 foreach ( $aaaa as $record ) {
194 if ( ! empty( $record['ipv6'] ) ) {
195 $ipv6[] = $record['ipv6'];
196 }
197 }
198 }
199 }
200 }
201
202 if ( ! $ipv4 && ! $ipv6 ) {
203 return false;
204 }
205
206 // A host that resolves may be used as is when non-public hosts are explicitly allowed.
207 if ( $allow_non_public ) {
208 return $ipv4[0] ?? $ipv6[0];
209 }
210
211 foreach ( $ipv4 as $ip ) {
212 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
213 return false;
214 }
215 }
216
217 foreach ( $ipv6 as $ip ) {
218 if ( is_unsafe_ipv6_literal( $ip ) ) {
219 return false;
220 }
221 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
222 return false;
223 }
224 }
225
226 return $ipv4[0] ?? $ipv6[0];
227 }
228
229 /**
230 * Detect IPv4-mapped IPv6 literals (`::ffff:0:0/96`).
231 *
232 * PHP's FILTER_FLAG_NO_RES_RANGE catches this range on some builds but not
233 * others. These forms serve no legitimate purpose for the SSRF-guard callers,
234 * so reject the entire range explicitly via packed-byte comparison.
235 *
236 * @param string $ip An IP literal.
237 *
238 * @return bool True if the value is an IPv4-mapped IPv6 address.
239 */
240 function is_ipv4_mapped_ipv6( $ip ) {
241 // Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input.
242 if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
243 return false;
244 }
245
246 $packed = \inet_pton( $ip );
247
248 return false !== $packed
249 && 16 === \strlen( $packed )
250 && "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 );
251 }
252
253 /**
254 * Detect IPv6 literals in transitional / special-use ranges that PHP's
255 * FILTER_FLAG_NO_RES_RANGE doesn't reliably block.
256 *
257 * Covers, in addition to the IPv4-mapped range handled by
258 * {@see is_ipv4_mapped_ipv6()}:
259 *
260 * - `2002::/16` — 6to4 (RFC 3056). Embeds an IPv4 address in the next 32 bits,
261 * so e.g. `2002:7f00:0001::1` routes back to `127.0.0.1` on a host with 6to4.
262 * - `2001:0000::/32` — Teredo tunneling (RFC 4380). The check matches the
263 * exact 32-bit prefix `2001:0000`, so legitimate `2001::/16` global unicast
264 * allocations (e.g. Google DNS `2001:4860::/32`) are unaffected. The
265 * `2001:db8::/32` documentation range is also blocked, by its own entry
266 * below — they're separate `2001::/16` sub-allocations.
267 * - `2001:db8::/32` — Documentation prefix (RFC 3849); should never be routed.
268 * - `64:ff9b::/96` — NAT64 well-known prefix (RFC 6052).
269 * - `64:ff9b:1::/48` — NAT64 local-use prefix (RFC 8215).
270 * - `100::/64` — Discard prefix (RFC 6666).
271 *
272 * Returns false for IPv4 literals, hostnames, and IPv6 literals outside the
273 * listed ranges.
274 *
275 * @param string $ip An IP literal.
276 *
277 * @return bool True if the value is an unsafe IPv6 literal.
278 */
279 function is_unsafe_ipv6_literal( $ip ) {
280 if ( ! \is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
281 return false;
282 }
283
284 $packed = \inet_pton( $ip );
285 if ( false === $packed || 16 !== \strlen( $packed ) ) {
286 return false;
287 }
288
289 // IPv4-mapped IPv6 prefix.
290 if ( "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 ) ) {
291 return true;
292 }
293
294 // 6to4 prefix.
295 if ( "\x20\x02" === \substr( $packed, 0, 2 ) ) {
296 return true;
297 }
298
299 // Teredo prefix.
300 if ( "\x20\x01\x00\x00" === \substr( $packed, 0, 4 ) ) {
301 return true;
302 }
303
304 // Documentation prefix.
305 if ( "\x20\x01\x0d\xb8" === \substr( $packed, 0, 4 ) ) {
306 return true;
307 }
308
309 // NAT64 well-known prefix.
310 if ( "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 12 ) ) {
311 return true;
312 }
313
314 // NAT64 local-use prefix.
315 if ( "\x00\x64\xff\x9b\x00\x01" === \substr( $packed, 0, 6 ) ) {
316 return true;
317 }
318
319 // Discard prefix.
320 if ( "\x01\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 8 ) ) {
321 return true;
322 }
323
324 return false;
325 }
326