PluginProbe
ActivityPub / 9.0.0
ActivityPub v9.0.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.0.0, at includes/functions-request.php

300 lines 9.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 // Already an IP literal — validate directly. Accepts IPv4 and IPv6.
139 if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
140 if ( is_unsafe_ipv6_literal( $host ) ) {
141 return false;
142 }
143
144 return \filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )
145 ? $host
146 : false;
147 }
148
149 /**
150 * Filters the resolved addresses for a hostname before validation.
151 *
152 * Returning a non-null array of `array{ipv4: string[], ipv6: string[]}` skips
153 * the DNS lookup. Tests use this to exercise the validation/preference logic
154 * without making real DNS queries; production code should leave it null.
155 *
156 * @param array{ipv4: string[], ipv6: string[]}|null $pre Pre-resolved addresses, or null to perform DNS lookup.
157 * @param string $host The hostname being resolved.
158 */
159 $pre = \apply_filters( 'activitypub_pre_resolve_public_host', null, $host );
160
161 if ( \is_array( $pre ) ) {
162 $ipv4 = isset( $pre['ipv4'] ) && \is_array( $pre['ipv4'] ) ? $pre['ipv4'] : array();
163 $ipv6 = isset( $pre['ipv6'] ) && \is_array( $pre['ipv6'] ) ? $pre['ipv6'] : array();
164 } else {
165 $ipv4 = \gethostbynamel( $host ) ?: array();
166 $ipv6 = array();
167
168 if ( \function_exists( 'dns_get_record' ) ) {
169 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on lookup failure; we already handle the empty case.
170 $aaaa = @\dns_get_record( $host, DNS_AAAA );
171 if ( \is_array( $aaaa ) ) {
172 foreach ( $aaaa as $record ) {
173 if ( ! empty( $record['ipv6'] ) ) {
174 $ipv6[] = $record['ipv6'];
175 }
176 }
177 }
178 }
179 }
180
181 if ( ! $ipv4 && ! $ipv6 ) {
182 return false;
183 }
184
185 foreach ( $ipv4 as $ip ) {
186 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
187 return false;
188 }
189 }
190
191 foreach ( $ipv6 as $ip ) {
192 if ( is_unsafe_ipv6_literal( $ip ) ) {
193 return false;
194 }
195 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
196 return false;
197 }
198 }
199
200 return $ipv4[0] ?? $ipv6[0];
201 }
202
203 /**
204 * Detect IPv4-mapped IPv6 literals (`::ffff:0:0/96`).
205 *
206 * PHP's FILTER_FLAG_NO_RES_RANGE catches this range on some builds but not
207 * others. These forms serve no legitimate purpose for the SSRF-guard callers,
208 * so reject the entire range explicitly via packed-byte comparison.
209 *
210 * @param string $ip An IP literal.
211 *
212 * @return bool True if the value is an IPv4-mapped IPv6 address.
213 */
214 function is_ipv4_mapped_ipv6( $ip ) {
215 // Short-circuit before inet_pton() so it doesn't emit a warning for non-IP input.
216 if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
217 return false;
218 }
219
220 $packed = \inet_pton( $ip );
221
222 return false !== $packed
223 && 16 === \strlen( $packed )
224 && "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 );
225 }
226
227 /**
228 * Detect IPv6 literals in transitional / special-use ranges that PHP's
229 * FILTER_FLAG_NO_RES_RANGE doesn't reliably block.
230 *
231 * Covers, in addition to the IPv4-mapped range handled by
232 * {@see is_ipv4_mapped_ipv6()}:
233 *
234 * - `2002::/16` — 6to4 (RFC 3056). Embeds an IPv4 address in the next 32 bits,
235 * so e.g. `2002:7f00:0001::1` routes back to `127.0.0.1` on a host with 6to4.
236 * - `2001:0000::/32` — Teredo tunneling (RFC 4380). The check matches the
237 * exact 32-bit prefix `2001:0000`, so legitimate `2001::/16` global unicast
238 * allocations (e.g. Google DNS `2001:4860::/32`) are unaffected. The
239 * `2001:db8::/32` documentation range is also blocked, by its own entry
240 * below — they're separate `2001::/16` sub-allocations.
241 * - `2001:db8::/32` — Documentation prefix (RFC 3849); should never be routed.
242 * - `64:ff9b::/96` — NAT64 well-known prefix (RFC 6052).
243 * - `64:ff9b:1::/48` — NAT64 local-use prefix (RFC 8215).
244 * - `100::/64` — Discard prefix (RFC 6666).
245 *
246 * Returns false for IPv4 literals, hostnames, and IPv6 literals outside the
247 * listed ranges.
248 *
249 * @param string $ip An IP literal.
250 *
251 * @return bool True if the value is an unsafe IPv6 literal.
252 */
253 function is_unsafe_ipv6_literal( $ip ) {
254 if ( ! is_string( $ip ) || ! \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
255 return false;
256 }
257
258 $packed = \inet_pton( $ip );
259 if ( false === $packed || 16 !== \strlen( $packed ) ) {
260 return false;
261 }
262
263 // IPv4-mapped IPv6 prefix.
264 if ( "\0\0\0\0\0\0\0\0\0\0\xff\xff" === \substr( $packed, 0, 12 ) ) {
265 return true;
266 }
267
268 // 6to4 prefix.
269 if ( "\x20\x02" === \substr( $packed, 0, 2 ) ) {
270 return true;
271 }
272
273 // Teredo prefix.
274 if ( "\x20\x01\x00\x00" === \substr( $packed, 0, 4 ) ) {
275 return true;
276 }
277
278 // Documentation prefix.
279 if ( "\x20\x01\x0d\xb8" === \substr( $packed, 0, 4 ) ) {
280 return true;
281 }
282
283 // NAT64 well-known prefix.
284 if ( "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 12 ) ) {
285 return true;
286 }
287
288 // NAT64 local-use prefix.
289 if ( "\x00\x64\xff\x9b\x00\x01" === \substr( $packed, 0, 6 ) ) {
290 return true;
291 }
292
293 // Discard prefix.
294 if ( "\x01\x00\x00\x00\x00\x00\x00\x00" === \substr( $packed, 0, 8 ) ) {
295 return true;
296 }
297
298 return false;
299 }
300