'https://' . $host . self::WELL_KNOWN, 'host' => $host, ); } /** * @param string $directory_url * @return array> */ public function fetch_keys( $directory_url ) { $guard = self::directory_url_from_agent_header( '"' . $directory_url . '"' ); $allowed = 'https://' . ( is_array( $guard ) ? $guard['host'] : '' ) . self::WELL_KNOWN; if ( ! is_array( $guard ) || $allowed !== $directory_url ) { return array(); } // The cache answers first, so a repeated host costs no DNS lookup and no fetch. $cache_key = 'agent_stamp_dir_' . md5( $directory_url ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $ips = self::public_ips( $guard['host'] ); if ( empty( $ips ) ) { set_transient( $cache_key, array(), 10 * MINUTE_IN_SECONDS ); return array(); } // The connection is pinned to the addresses that just passed the check, so a // DNS answer that changes between the check and the request cannot redirect it. // wp_safe_remote_get() then runs WordPress's own URL check (wp_http_validate_url) // at request time as well. $host = $guard['host']; $pin = static function ( $handle, $parsed_args, $url ) use ( $directory_url, $host, $ips ) { if ( $url === $directory_url ) { self::pin_connection( $handle, $host, $ips ); } }; add_action( 'http_api_curl', $pin, 10, 3 ); $response = wp_safe_remote_get( $directory_url, array( 'timeout' => 2, 'redirection' => 0, 'limit_response_size' => 65536, 'headers' => array( 'Accept' => 'application/json, application/http-message-signatures-directory+json', ), ) ); remove_action( 'http_api_curl', $pin, 10 ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { set_transient( $cache_key, array(), 10 * MINUTE_IN_SECONDS ); return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); $keys = array(); if ( is_array( $data ) && isset( $data['keys'] ) && is_array( $data['keys'] ) ) { foreach ( array_slice( $data['keys'], 0, 20 ) as $key ) { if ( is_array( $key ) ) { $keys[] = $key; } } } set_transient( $cache_key, $keys, HOUR_IN_SECONDS ); return $keys; } /** * @param string $header * @return string|null */ private static function agent_url( $header ) { $header = trim( (string) $header ); if ( '' === $header ) { return null; } if ( '"' === $header[0] ) { $end = strpos( $header, '"', 1 ); if ( false === $end ) { return null; } return substr( $header, 1, $end - 1 ); } if ( 1 === preg_match( '/=\s*"([^"]+)"/', $header, $match ) ) { return $match[1]; } if ( 0 === strpos( $header, 'https://' ) ) { return $header; } return null; } /** * @param string $host * @return bool */ private static function public_host( $host ) { if ( '' === $host || false !== strpos( $host, '%' ) ) { return false; } $blocked = array( 'localhost', 'localhost.localdomain', 'metadata.google.internal' ); if ( in_array( $host, $blocked, true ) ) { return false; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return self::public_ip( $host ); } return 1 === preg_match( '/^[a-z0-9.-]+$/', $host ) && false !== strpos( $host, '.' ); } /** * Resolve a host and keep the result only when every address is public. * * @param string $host * @return string[] The public addresses, or an empty array when any address is not public. */ public static function public_ips( $host ) { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return self::public_ip( $host ) ? array( $host ) : array(); } $ips = array(); if ( function_exists( 'dns_get_record' ) ) { $records = @dns_get_record( $host, DNS_A + DNS_AAAA ); if ( is_array( $records ) ) { foreach ( $records as $record ) { if ( isset( $record['ip'] ) ) { $ips[] = $record['ip']; } if ( isset( $record['ipv6'] ) ) { $ips[] = $record['ipv6']; } } } } if ( empty( $ips ) ) { $v4 = @gethostbynamel( $host ); if ( is_array( $v4 ) ) { $ips = $v4; } } $ips = array_values( array_unique( $ips ) ); foreach ( $ips as $ip ) { if ( ! self::public_ip( $ip ) ) { return array(); } } return $ips; } /** * Make a cURL handle connect to the given addresses for this host instead of * resolving the name again. Hostname, TLS SNI and certificate check are unchanged. * * @param mixed $handle cURL handle from the http_api_curl action. * @param string $host * @param string[] $ips * @return bool Whether the pin was applied. */ public static function pin_connection( $handle, $host, $ips ) { if ( ! function_exists( 'curl_setopt' ) || ! defined( 'CURLOPT_RESOLVE' ) || empty( $ips ) ) { return false; } $entry = $host . ':443:' . implode( ',', $ips ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Pins the WordPress HTTP API's own cURL handle to the addresses that passed the public-IP check. return (bool) curl_setopt( $handle, CURLOPT_RESOLVE, array( $entry ) ); } /** * @param string $ip * @return bool */ private static function public_ip( $ip ) { return false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } } /** * WordPress transient nonce store. */ class AgentStamp_Wp_Nonces implements AgentStamp_Nonce_Store { /** * @param string $nonce * @param int $expires_at * @return bool */ public function claim( $nonce, $expires_at ) { $key = 'agent_stamp_n_' . md5( $nonce ); if ( false !== get_transient( $key ) ) { return false; } $ttl = max( 60, (int) $expires_at - time() + AgentStamp_Signature::SKEW_SECONDS ); set_transient( $key, '1', $ttl ); return true; } }