0`.** A redirect is how a host somebody vetted quietly becomes one they did * not, and following it would carry the Authorization header to the new address. The guard below * checked where the request was *going*; it cannot check where a 302 leads. * - **The SSRF guard, re-asked here.** Not when the URL was saved — DNS moves, and a hostname that * resolved publicly when it was typed can resolve to 169.254.169.254 by the time it is used. * - **A per-host rate limit.** One misbehaving loop should cost a connector its own budget, not the * site's IP address on somebody else's abuse list. * * IT DOES NOT RETRY, AND THAT IS THE POINT OF HAVING A QUEUE. Retrying inside the request would hold * a cron run open across somebody else's outage. Work that must survive a failure goes on * {@see \PlugixaCrm\Modules\Integrations\Repositories\JobRepository}, which already has the * attempt counter, the exponential backoff and the give-up rule. Two retry mechanisms would be two * places to reason about how long a broken integration keeps trying, and they would disagree. */ final class HttpClient { /** Seconds. Long enough for a slow API, short enough not to hold a cron run open. */ private const TIMEOUT = 15; /** Requests allowed per host inside {@see RATE_WINDOW}. */ private const RATE_LIMIT = 60; /** Seconds the limit is measured over. */ private const RATE_WINDOW = 60; public function __construct( private readonly DestinationGuard $guard ) {} /** * GET. * * @param string $url Absolute URL. * @param array $headers Extra headers. * * @return array{status: int, body: string, headers: array}|WP_Error */ public function get( string $url, array $headers = array() ): array|WP_Error { return $this->request( 'GET', $url, null, $headers ); } /** * POST a body as-is. The caller decides whether it is JSON or form-encoded. * * @param string $url Absolute URL. * @param string $body Raw body. * @param array $headers Extra headers. * * @return array{status: int, body: string, headers: array}|WP_Error */ public function post( string $url, string $body, array $headers = array() ): array|WP_Error { return $this->request( 'POST', $url, $body, $headers ); } /** * POST JSON. * * @param string $url Absolute URL. * @param array $data Encoded as JSON. * @param array $headers Extra headers. * * @return array{status: int, body: string, headers: array}|WP_Error */ public function post_json( string $url, array $data, array $headers = array() ): array|WP_Error { $body = wp_json_encode( $data ); if ( ! is_string( $body ) ) { return new WP_Error( 'plugixa_crm_encode_failed', __( 'That request could not be encoded.', 'plugixa-crm' ) ); } return $this->request( 'POST', $url, $body, array_merge( array( 'Content-Type' => 'application/json' ), $headers ) ); } /** * The single request path. * * @param string $method HTTP verb. * @param string $url Absolute URL. * @param string|null $body Raw body, or null. * @param array $headers Extra headers. * * @return array{status: int, body: string, headers: array}|WP_Error */ public function request( string $method, string $url, ?string $body = null, array $headers = array() ): array|WP_Error { if ( ! $this->guard->allows( $url ) ) { /* * The message names the address and not the reason. Saying "that resolves to a private * range" to whoever typed the URL is useful; saying it to whoever is PROBING the URL maps * the network from outside, one refusal at a time. The administrator has the filter * (`plugixa_crm_outbound_destination_allowed`) and the documentation; a prober gets a * refusal that tells them nothing they did not already supply. */ return new WP_Error( 'plugixa_crm_destination_refused', __( 'That address is not one this site is allowed to contact.', 'plugixa-crm' ) ); } $host = (string) ( wp_parse_url( $url, PHP_URL_HOST ) ?? '' ); if ( ! $this->within_rate_limit( $host ) ) { return new WP_Error( 'plugixa_crm_rate_limited', __( 'Too many requests to that service just now. It will be retried shortly.', 'plugixa-crm' ) ); } $args = array( 'method' => $method, 'timeout' => self::TIMEOUT, 'redirection' => 0, 'blocking' => true, 'headers' => array_merge( array( 'User-Agent' => 'PlugixaCRM/' . PLUGIXA_CRM_VERSION ), $headers ), ); if ( null !== $body ) { $args['body'] = $body; } $response = wp_remote_request( $url, $args ); if ( $response instanceof WP_Error ) { return $response; } /* * `(array)` ON THE HEADERS OBJECT IS THE WRONG CAST, and it produced a PHP notice on any * response at all. `wp_remote_retrieve_headers()` returns a `CaseInsensitiveDictionary`, not * an array, and casting an object to an array exposes its PRIVATE PROPERTIES rather than its * contents — so the loop ran exactly once, with `$name` of `data` and `$value` of the whole * header map, and `implode()` on a map whose values are themselves arrays raised * "Array to string conversion" and wrote the word `Array` into the result. * * Every header this client returned was therefore a single bogus `data` entry. Nothing had * noticed because no connector reads a response header yet — the first test to exercise this * path is the one that made the notice visible. * * `getAll()` is the dictionary's own accessor and returns the real map. */ $headers_object = wp_remote_retrieve_headers( $response ); $raw = is_object( $headers_object ) && method_exists( $headers_object, 'getAll' ) ? $headers_object->getAll() : (array) $headers_object; $out = array(); foreach ( $raw as $name => $value ) { $out[ strtolower( (string) $name ) ] = is_array( $value ) ? implode( ', ', array_map( 'strval', $value ) ) : (string) $value; } return array( 'status' => (int) wp_remote_retrieve_response_code( $response ), 'body' => (string) wp_remote_retrieve_body( $response ), 'headers' => $out, ); } /** * A fixed window per host. * * KEYED ON A HASH OF THE HOST, not the host itself. Transient names end up in `wp_options` on * sites without object caching, and a row called `_transient_plugixa_crm_rate_api.acme-crm.com` * tells anybody who can read that table which services this site integrates with — which is * reconnaissance, from a rate limiter. * * THE WINDOW IS IN THE KEY, AND THAT DIFFERS FROM `PublicRequestGuard` ON PURPOSE. That one keeps * a single key per bucket and calls `set_transient()` with a fresh expiry on every request, which * pushes the window forward for as long as traffic continues. For a public form that is harmless * and arguably desirable — sustained abuse stays blocked. Here it would be a bug with a long tail: * a connector that legitimately makes 60 requests a minute would extend its own window on every * call and stay refused indefinitely, so one busy import would leave an integration permanently * broken with nothing in any log to explain it. * * Putting the window number in the key gives each window its own transient and its own expiry, so * the count genuinely resets. The classic fixed-window edge remains — a burst straddling two * windows can briefly reach 2×limit — and for a runaway-loop guard that is fine. * * @param string $host The destination host. */ private function within_rate_limit( string $host ): bool { if ( '' === $host ) { return true; } $window = (int) floor( time() / self::RATE_WINDOW ); $key = 'plugixa_crm_http_' . hash( 'sha256', $host . '|' . $window ); $count = (int) get_transient( $key ); if ( $count >= self::RATE_LIMIT ) { return false; } // Twice the window, so the row outlives its own bucket rather than expiring mid-count on a // site whose clock and cache disagree by a second. It is superseded by the next bucket's key // either way, so a stale row is never read. set_transient( $key, $count + 1, self::RATE_WINDOW * 2 ); return true; } }