| @@ -118,198 +118,4 @@ | ||
| 118 | 118 | } |
| 119 | 119 | |
| 120 | 120 | return json_decode( $remote_actor->post_content, true ); |
| 121 | 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 | -} | |