$status, 'http_code' => 0, 'retry_after' => 0 ); }; if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ) { return $fail( 'external_http_blocked' ); } if ( ! function_exists( 'gethostbynamel' ) || ! defined( 'CURLOPT_RESOLVE' ) || ! class_exists( '\WpOrg\Requests\Transport\Curl' ) || ! \WpOrg\Requests\Transport\Curl::test( array( 'ssl' => true ) ) ) { return $fail( 'transport_unavailable' ); } $host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) ); $addresses = gethostbynamel( $host ); if ( ! is_array( $addresses ) || ! $addresses ) { return $fail( 'dns_failed' ); } foreach ( $addresses as $address ) { if ( ! KCONV_Newsletter_Transport::public_ipv4( $address ) ) { return $fail( 'blocked_address' ); } } $pinned = $addresses[0]; $ca_file = ABSPATH . WPINC . '/certificates/ca-bundle.crt'; if ( ! is_readable( $ca_file ) ) { return $fail( 'tls_ca_unavailable' ); } $marker = wp_generate_uuid4(); $identity = new stdClass(); $state = (object) array( 'aborted' => false, 'prepared' => false, 'configured' => false, 'completed' => false, 'handle' => null, 'status' => 'transport_failed', 'code' => 0, 'retry_after' => 0, 'header_bytes' => 0, 'body_bytes' => 0 ); $transport = null; $outgoing = array_merge( array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Expect' => '' ), $headers ); // This correlation header is removed by requests.before_request, before transport setup. $outgoing['X-KillerConversions-Transport'] = $marker; $request_args = array( 'method' => $method, 'body' => $body, 'headers' => $outgoing, 'user-agent' => $user_agent, 'timeout' => 8, 'redirection' => 0, 'blocking' => true, 'sslverify' => true, 'sslcertificates' => $ca_file, 'reject_unsafe_urls' => true, 'decompress' => false, 'compress' => false, 'cookies' => array(), 'stream' => false, 'filename' => null, 'limit_response_size' => 16384, 'kconv_transport_identity' => $identity, ); // Bracket argument filters so stripping both markers cannot turn this into // an ordinary unpinned request. Nested HTTP calls get their own stack frame. $frames = array(); $begin_args = static function ( $args ) use ( $identity, &$frames ) { $frames[] = isset( $args['kconv_transport_identity'] ) && $identity === $args['kconv_transport_identity']; return $args; }; $end_args = static function ( $args, $request_url ) use ( $url, $request_args, &$frames, $state ) { if ( ! array_pop( $frames ) ) { return $args; } $valid = $url === $request_url; foreach ( $request_args as $key => $value ) { if ( ! array_key_exists( $key, $args ) || $args[ $key ] !== $value ) { $valid = false; break; } } if ( ! $valid ) { $state->status = 'transport_configuration'; throw new \WpOrg\Requests\Exception( 'The delivery request arguments changed.', 'kconv_transport_configuration' ); } return $args; }; $request_frames = array(); $begin_request = static function ( &$request_url, &$request_headers, &$data, &$type, &$options ) use ( $marker, &$request_frames ) { $owned = isset( $request_headers['X-KillerConversions-Transport'] ) && $marker === $request_headers['X-KillerConversions-Transport']; $request_frames[] = $owned ? $options['hooks'] : false; }; $prepare = static function ( &$request_url, &$request_headers, &$data, &$type, &$options ) use ( $url, $body, $method, $marker, $outgoing, $ca_file, $pinned, &$transport, &$request_frames, $state ) { $request_hooks = array_pop( $request_frames ); if ( false === $request_hooks ) { return; } if ( $state->prepared || $request_url !== $url || $data !== $body || $type !== $method || ! isset( $request_headers['X-KillerConversions-Transport'] ) || $marker !== $request_headers['X-KillerConversions-Transport'] || $options['hooks'] !== $request_hooks || $request_headers !== $outgoing || $options['type'] !== $method || 'body' !== $options['data_format'] ) { $state->status = 'transport_configuration'; throw new \WpOrg\Requests\Exception( 'The delivery request changed before execution.', 'kconv_transport_configuration' ); } unset( $request_headers['X-KillerConversions-Transport'] ); $state->prepared = true; $transport = new \WpOrg\Requests\Transport\Curl(); $options['transport'] = $transport; $options['proxy'] = false; $options['follow_redirects'] = false; $options['redirects'] = 0; $options['verify'] = $ca_file; $options['verifyname'] = true; $options['blocking'] = true; $options['filename'] = false; $options['timeout'] = 8; $options['connect_timeout'] = 3; $options['max_bytes'] = 16384; $options['hooks']->register( 'curl.before_send', static function ( &$handle ) use ( $state ) { $state->handle = $handle; } ); // Requests retries CURLE_WRITE_ERROR after this hook. Stop here before // that retry; throwing inside a cURL callback emits warnings on PHP 7.4. $options['hooks']->register( 'curl.after_send', static function () use ( $state ) { if ( $state->aborted ) { throw new \WpOrg\Requests\Exception( 'The delivery response exceeded its transport limits.', 'kconv_transport_limit' ); } } ); $options['hooks']->register( 'curl.after_request', static function ( &$raw, &$info ) use ( $pinned, $state ) { if ( empty( $info['primary_ip'] ) || $pinned !== $info['primary_ip'] ) { $state->status = 'transport_address'; throw new \WpOrg\Requests\Exception( 'The delivery connection did not use its validated address.', 'kconv_transport_address' ); } $state->code = isset( $info['http_code'] ) ? (int) $info['http_code'] : 0; $state->completed = true; } ); }; $configure = static function ( $handle, $args, $request_url ) use ( $url, $host, $pinned, $ca_file, &$transport, $state ) { if ( $handle !== $state->handle ) { return; } if ( $request_url !== $url || ! $state->prepared || $state->configured || ! $transport ) { $state->status = 'transport_configuration'; throw new \WpOrg\Requests\Exception( 'The delivery transport was not prepared.', 'kconv_transport_configuration' ); } $settings = array( CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 8, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CAINFO => $ca_file, CURLOPT_PROXY => '', CURLOPT_NOPROXY => '*', CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_RESOLVE => array( $host . ':443:' . $pinned ), CURLOPT_FRESH_CONNECT => true, CURLOPT_FORBID_REUSE => true, // Avoid Requests retrying a provider POST after a decoding error. CURLOPT_ENCODING => 'identity', CURLOPT_HTTP_CONTENT_DECODING => false, CURLOPT_HEADERFUNCTION => static function ( $curl_handle, $line ) use ( $handle, $transport, $state ) { $state->header_bytes += strlen( $line ); if ( $curl_handle !== $handle || $state->header_bytes > 32768 ) { $state->aborted = true; return 0; } if ( preg_match( '~^HTTP/\S+\s+(\d{3})~', $line, $match ) ) { $state->code = (int) $match[1]; } if ( 0 === stripos( $line, 'Retry-After:' ) ) { $state->retry_after = KCONV_Newsletter_Transport::retry_after( trim( substr( $line, 12 ) ) ); } return $transport->stream_headers( $curl_handle, $line ); }, CURLOPT_WRITEFUNCTION => static function ( $curl_handle, $chunk ) use ( $handle, $state ) { $state->body_bytes += strlen( $chunk ); if ( $curl_handle !== $handle || $state->body_bytes > 16384 ) { $state->aborted = true; return 0; } // Only status and Retry-After are consumed. Discard body bytes, // as before: Requests must never decompress an untrusted body. return strlen( $chunk ); }, ); if ( defined( 'CURLOPT_PROTOCOLS_STR' ) ) { $settings[ CURLOPT_PROTOCOLS_STR ] = 'https'; } elseif ( defined( 'CURLOPT_PROTOCOLS' ) ) { $settings[ CURLOPT_PROTOCOLS ] = CURLPROTO_HTTPS; } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- Configure the WordPress HTTP API handle only through its documented http_api_curl hook; WordPress executes and owns the request. if ( ! curl_setopt_array( $handle, $settings ) ) { $state->status = 'transport_configuration'; throw new \WpOrg\Requests\Exception( 'The delivery transport could not be configured.', 'kconv_transport_configuration' ); } $state->configured = true; }; $require_pin = static function ( &$handle ) use ( $state ) { if ( $handle === $state->handle && ! $state->configured ) { $state->status = 'transport_configuration'; throw new \WpOrg\Requests\Exception( 'The delivery transport was not pinned.', 'kconv_transport_configuration' ); } }; add_filter( 'http_request_args', $begin_args, PHP_INT_MIN, 1 ); add_filter( 'http_request_args', $end_args, PHP_INT_MAX, 2 ); add_action( 'requests-curl.before_send', $require_pin, PHP_INT_MAX, 1 ); add_action( 'requests-requests.before_request', $begin_request, PHP_INT_MIN, 5 ); add_action( 'requests-requests.before_request', $prepare, PHP_INT_MAX, 5 ); add_action( 'http_api_curl', $configure, PHP_INT_MAX, 3 ); try { $response = wp_remote_request( $url, $request_args ); // A filter may short-circuit HTTP; it cannot manufacture a pinned receipt. if ( ! is_wp_error( $response ) && $state->configured && $state->completed && $state->code === (int) wp_remote_retrieve_response_code( $response ) ) { $state->status = $state->code >= 200 && $state->code < 300 ? 'accepted' : 'http_failed'; } } catch ( \Throwable $error ) { // Keep failure output bounded and never include credentials or response data. } finally { remove_filter( 'http_request_args', $begin_args, PHP_INT_MIN ); remove_filter( 'http_request_args', $end_args, PHP_INT_MAX ); remove_action( 'requests-curl.before_send', $require_pin, PHP_INT_MAX ); remove_action( 'requests-requests.before_request', $begin_request, PHP_INT_MIN ); remove_action( 'requests-requests.before_request', $prepare, PHP_INT_MAX ); remove_action( 'http_api_curl', $configure, PHP_INT_MAX ); } return array( 'status' => $state->status, 'http_code' => $state->code, 'retry_after' => $state->retry_after ); } }