PluginProbe
ActivityPub / 8.2.0
ActivityPub v8.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 8.2.0, at includes/functions-request.php

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